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

Reviewed-on: #433
Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
2026-10-20 20:44:14 +09:00
committed by みてるぞ
コミット 084a090cab
294個のファイルの変更、13978行の追加、3461行の削除
+32 -21
ファイルの表示
@@ -1,11 +1,13 @@
import { animate, motion, useMotionTemplate, useMotionValue } from 'framer-motion'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useLocale } from '@/lib/useLocale'
import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { useUITranslation } from '@/lib/useUITranslation'
import { useHydrated } from '@/lib/useHydrated'
import { expectedAnswerForQuestion,
fetchGekanatorExtraQuestions,
fetchGekanatorQuestions,
@@ -3678,21 +3680,32 @@ const expectedAnswerFor = (
const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
const hydrated = useHydrated ()
const initialSeed = useId ()
const { message } = useUITranslation ('gekanator')
const locale = useLocale ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const storedGame = useMemo (loadStoredGame, [])
const storedGame = useMemo (() => hydrated ? loadStoredGame () : null, [hydrated])
const hasStoredRestore = storedGame != null && isStoredPhase (storedGame.phase)
const queryClient = useQueryClient ()
const isAdmin = user?.role === 'admin'
const canPersistGame = user != null
const [recentGames, setRecentGames] = useState<RecentGameSummary[]> (
() => loadRecentGames ())
() => hydrated ? loadRecentGames () : [])
const [backgroundMotionMode, setBackgroundMotionMode] = useState<BackgroundMotionMode> (
() => loadBackgroundMotionMode ())
() => hydrated ? loadBackgroundMotionMode () : 'on')
const [prefersReducedMotion, setPrefersReducedMotion] = useState (false)
const [gameSeed, setGameSeed] = useState (
storedGame?.gameSeed ?? createGameSeed ())
storedGame?.gameSeed ?? initialSeed)
const [restorePromptVisible, setRestorePromptVisible] = useState (hasStoredRestore)
useEffect (() => {
if (!hydrated)
return
setRecentGames (loadRecentGames ())
setBackgroundMotionMode (loadBackgroundMotionMode ())
setRestorePromptVisible (hasStoredRestore)
}, [hydrated, hasStoredRestore])
const [phase, setPhase] = useState<Phase> (
hasStoredRestore ? 'intro' : storedGame?.phase ?? 'intro')
const [scores, setScores] = useState<Map<number, number>> (
@@ -3771,8 +3784,8 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
const [history, setHistory] = useState<GameSnapshot[]> ([])
const { data: posts = [], isLoading, error } = useQuery ({
queryKey: gekanatorKeys.posts (),
queryFn: fetchGekanatorPosts,
queryKey: gekanatorKeys.posts (locale),
queryFn: () => fetchGekanatorPosts (locale),
refetchOnWindowFocus: false })
const {
data: acceptedQuestions = [],
@@ -3780,8 +3793,8 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
isLoading: acceptedQuestionsLoading,
error: acceptedQuestionsError
} = useQuery ({
queryKey: gekanatorKeys.questions (),
queryFn: fetchGekanatorQuestions,
queryKey: gekanatorKeys.questions (locale),
queryFn: () => fetchGekanatorQuestions (locale),
select: questions => questions.map (restoreGekanatorQuestion),
refetchOnWindowFocus: false })
const materialIndex = useMemo (() => buildMaterialIndex (posts), [posts])
@@ -4103,7 +4116,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
const mascotAsset = mascotAssetByState[mascot]
const mascotAlt = mascotAltByState[mascot]
const saveMutation = useMutation ({
mutationFn: saveGekanatorGame,
mutationFn: (variables: Parameters<typeof saveGekanatorGame>[0]) => saveGekanatorGame (variables, locale),
onSuccess: (data, variables) => {
setRecentGames (storeRecentGameSummary ({
correctPostId: variables.correctPostId,
@@ -4114,9 +4127,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setLearnedExampleCount (data.learnedExampleCount)
setResultWon (variables.guessedPostId === variables.correctPostId)}})
const questionSuggestionMutation = useMutation ({
mutationFn: saveGekanatorQuestionSuggestion,
mutationFn: (variables: Parameters<typeof saveGekanatorQuestionSuggestion>[0]) => saveGekanatorQuestionSuggestion (variables, locale),
onSuccess: async data => {
await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () })
await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions (locale) })
setQuestionSuggestionCount (data.count)
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestionSearch ('')
@@ -4124,9 +4137,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setQuestionSuggestion ('')
setQuestionSuggestionAnswer ('yes')}})
const extraQuestionAnswersMutation = useMutation ({
mutationFn: saveGekanatorExtraQuestionAnswers,
mutationFn: (variables: Parameters<typeof saveGekanatorExtraQuestionAnswers>[0]) => saveGekanatorExtraQuestionAnswers (variables, locale),
onSuccess: async () => {
await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () })
await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions (locale) })
setExtraQuestionState ('saved')
setPhase ('end')}})
@@ -4784,8 +4797,8 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
try
{
const questions = await queryClient.fetchQuery ({
queryKey: gekanatorKeys.extraQuestions (gameId, nonce),
queryFn: () => fetchGekanatorExtraQuestions (gameId, nonce) })
queryKey: gekanatorKeys.extraQuestions (gameId, nonce, locale),
queryFn: () => fetchGekanatorExtraQuestions (gameId, nonce, locale) })
setExtraQuestions (questions.slice (0, 2))
setExtraQuestionState (questions.length > 0 ? 'ready' : 'empty')
}
@@ -4912,9 +4925,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
return (
<MainArea className="relative isolate overflow-x-hidden bg-yellow-50 dark:bg-red-975">
<Helmet>
<title>{`グカネータ | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('title')}/>
<GekanatorBackdrop
posts={backgroundPosts}
+141
ファイルの表示
@@ -0,0 +1,141 @@
import { motion, useReducedMotion } from 'framer-motion'
import { useLayoutEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import nikumaru from '@/assets/fonts/nikumaru.otf'
import { formatUIString } from '@/components/LocalisedText'
import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import PageHead from '@/components/common/PageHead'
import { switchLocalePath } from '@/lib/locales'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useLocaleNavigate } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
const GekanatorUnavailablePage = () => {
const location = useLocation ()
const navigate = useLocaleNavigate ()
const behaviourSettings = useClientBehaviourSettings ()
const prefersReducedMotion = useReducedMotion ()
const shakeMascot = (behaviourSettings.animation ?? 'normal') === 'normal'
&& !prefersReducedMotion
const { message, label } = useUITranslation ('gekanator')
const title = message ('unavailable.title')
const imageAlt = message ('unavailable.imageAlt')
const headingRef = useRef<HTMLHeadingElement> (null)
useLayoutEffect (() => {
const heading = headingRef.current
const context = document.createElement ('canvas').getContext ('2d')
if (heading == null || context == null)
return
const fitTitle = () => {
heading.style.fontSize = ''
heading.style.maxWidth = ''
const style = window.getComputedStyle (heading)
const minimum = Number.parseFloat (style.fontSize)
context.font = `${style.fontWeight} ${minimum}px ${style.fontFamily}`
const width = context.measureText (formatUIString (title.text, title.language)).width
if (width > 0)
{
const available = heading.clientWidth
const fitted = minimum * Math.max (0, available - 1) / width
const fontSize = Math.max (minimum, fitted)
heading.style.fontSize = `${fontSize}px`
let lower = available / 2
let upper = available
// Keep the one-line font size and find a width that allows two lines.
for (let step = 0; step < 12; ++step)
{
const candidate = (lower + upper) / 2
heading.style.maxWidth = `${candidate}px`
if (heading.scrollHeight <= fontSize * 1.25 * 2 + 1
&& heading.scrollWidth <= heading.clientWidth)
upper = candidate
else
lower = candidate
}
heading.style.maxWidth = `${Math.ceil (upper)}px`
}
}
let disposed = false
const resizeObserver = new ResizeObserver (fitTitle)
resizeObserver.observe (heading)
window.addEventListener ('resize', fitTitle)
document.fonts.addEventListener ('loadingdone', fitTitle)
void document.fonts.ready.then (() => {
if (!disposed)
fitTitle ()
})
fitTitle ()
return () => {
disposed = true
resizeObserver.disconnect ()
window.removeEventListener ('resize', fitTitle)
document.fonts.removeEventListener ('loadingdone', fitTitle)
}
}, [title.text, title.language])
const goBack = () => {
// The router index excludes unrelated external history and is zero on direct entry.
const index: unknown = window.history.state?.idx
if (typeof index === 'number' && index > 0)
navigate (-1)
else
navigate ('/posts', { replace: true })
}
return (
<MainArea className="min-w-0">
<PageHead title={title}>
<meta name="robots" content="noindex"/>
<style>{`
@font-face {
font-family: 'Nikumaru';
src: url(${nikumaru}) format('opentype');
}
`}</style>
</PageHead>
<section className="mx-auto flex min-h-full w-full flex-col
items-center justify-center gap-6 py-6 text-center">
<h1 ref={headingRef} className="w-full text-5xl font-bold"
style={{ fontFamily: 'Nikumaru', lineHeight: 1.25 }}>
{label ('unavailable.title')}
</h1>
<motion.img
src="/assets/gekanator/mascot-failed.png"
alt={imageAlt.text}
lang={imageAlt.language}
width={1179}
height={1179}
animate={shakeMascot
? { x: [0, -10, 10, -8, 8, 0], y: [0, 8, -8, 10, -10, 0], rotate: 0 }
: { x: 0, y: 0, rotate: 0 }}
transition={{ duration: shakeMascot ? .16 : 0,
repeat: shakeMascot ? Infinity : 0, ease: 'linear' }}
className="h-auto max-h-[50dvh] w-full max-w-lg object-contain"/>
<div className="flex w-full flex-col items-stretch justify-center gap-3 md:flex-row">
<Button
type="button"
variant="outline"
onClick={goBack}
className="h-auto min-h-10 min-w-0 whitespace-normal">
{label ('unavailable.back')}
</Button>
<Button asChild className="h-auto min-h-10 min-w-0 whitespace-normal">
<PrefetchLink to={switchLocalePath (location, 'ja')}>
{label ('unavailable.switchToJapanese')}
</PrefetchLink>
</Button>
</div>
</section>
</MainArea>)
}
export default GekanatorUnavailablePage
+16 -8
ファイルの表示
@@ -1,10 +1,12 @@
import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import LocalisedText from '@/components/LocalisedText'
import { menuOutline } from '@/components/TopNav'
import SectionTitle from '@/components/common/SectionTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react'
@@ -12,20 +14,24 @@ import type { User } from '@/types'
const MorePage: FC = () => {
const location = useLocation ()
const { message } = useUITranslation ('shared')
const menu = menuOutline (
{ tag: null, wikiId: null, user: { } as User, pathName: location.pathname })
return (
<MainArea className="md:flex">
<Helmet>
<title>{`メニュー | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('labels.menu')}/>
{[...Array (Math.ceil (menu.length / 4)).keys ()].map (i => (
<div key={i} className="flex-1 mx-16">
{menu.slice (4 * i, 4 * (i + 1)).map ((item, j) => (
<section key={j}>
<SectionTitle className="font-bold">{item.name}</SectionTitle>
<SectionTitle className="font-bold">
<LocalisedText language={item.nameLanguage ?? 'ja'}>
{item.name}
</LocalisedText>
</SectionTitle>
<ul>
{item.subMenu
.filter (subItem => (subItem.visible ?? true))
@@ -36,7 +42,9 @@ const MorePage: FC = () => {
target={subItem.to.slice (0, 2) === '//'
? '_blank'
: undefined}>
{subItem.name}
<LocalisedText language={subItem.nameLanguage ?? 'ja'}>
{subItem.name}
</LocalisedText>
</PrefetchLink>
</li>)))}
</ul>
@@ -45,4 +53,4 @@ const MorePage: FC = () => {
</MainArea>)
}
export default MorePage
export default MorePage
+127
ファイルの表示
@@ -0,0 +1,127 @@
import MainArea from '@/components/layout/MainArea'
import { localisedDateString } from '@/lib/utils'
export const lastUpdatedAt = localisedDateString ('2026-04-12', 'ain-Kana', 'hour')
<MainArea>
<article lang="ain-Kana" className="prose dark:prose-invert mx-auto p-4">
# アエイワンケ イタㇰ
イオッタ アシㇼ アヌイェ ト: {lastUpdatedAt}
タン アエイワンケ イタㇰ(タプ ワ 「タン イタㇰ」 セコㇿ アニェ) アナㇰネ、ポチ チャ ロㇰ シクヌㇷ゚ イキㇼ チセ(タプ ワ 「タン チヘコテㇷ゚」 セコㇿ アニェ) アエイワンケ ヒ オㇿ アヌイェ イタㇰ ネ。 タン チヘコテㇷ゚ エイワンケ クㇽ アナㇰネ、エイワンケ ヒ タ タン イタㇰ エセ ルウェ ネ セコㇿ インカルㇱクㇽ ラム。
## 1 タン チヘコテㇷ゚ ネ ペ
1. タン チヘコテㇷ゚ アナㇰネ、シロシ、Wiki、ソイ ウコテㇷ゚ アウウェカㇻパ ワ、アエラムアン ペ ウココㇿ クス アエイワンケ シㇱテㇺ ネ。
2. タン チヘコテㇷ゚ オㇿ アナㇰネ、アカㇻペ シンナ サンケ ヒ アナㇰ ソモ シノ ポロ アタイ ネ。 シロシ クス アカㇻペ アウウェカㇻパ、アフナラ、スイ アヌカㇻ、ネワ ネワ イタㇰ アウコオㇷ゚テ ヒ アナㇰ シノ ポロ アタイ ネ。
3. インカルㇱクㇽ アナㇰネ、タン チヘコテㇷ゚ ネプキ クス コンルスイ ヤㇰネ、ネプキ カトゥ、アヌカレ ウㇱケ、レヘ、URL、アヌカレ ペ ネワ オヤ イアヌ シンナ カㇻ エアㇱカイ。
## 2 アヌカレ カトゥ ネワ イエイワンケクㇽ ウタㇻ
1. タン チヘコテㇷ゚ ホシキノ オピッタ クㇽ オルン アサンケ ヒ タ、**ネン ネ ヤッカ ヌカㇻ エアㇱカイ。 コㇿカ、アシンノ アサンケ ネワ シンナ カㇻ ヒ クス アコサウヌ クㇽ パテㇰ エアㇱカイ。**
2. ホシキノ オピッタ クㇽ オルン アサンケ ヒ タ、アヌン セコㇿ アン クㇽ アナㇰネ ヌカㇻ パテㇰ エアㇱカイ。
3. アサンケペ、シロシ、Wiki ネワ オヤ ペ シンナ カㇻ ヒ アナㇰネ、インカルㇱクㇽ アコサウヌ クㇽ、イヨロッ クㇽ セコㇿ アン クㇽ パテㇰ エアㇱカイ。
4. エプンキネ クㇽ セコㇿ アン クㇽ アナㇰネ、イヨロッ クㇽ エアㇱカイ ネプキ カ エアㇱカイ。 ソレ パㇰノ ソモ ネ、アヌイェペ ホシピレ、イサㇺカ、クㇽ イキ ヒ カㇱマ、オヤ エプンキネ ネプキ カ カㇻ エアㇱカイ。
5. インカルㇱクㇽ アナㇰネ、シンナ カㇻ ヒ フㇱコ アンペ アウカオ、ホシピレ、BAN、アヌカㇻ ワ アオㇿセ クス アン ル ネワ オヤ エプンキネ ネプキ ピㇼカノ アカㇻ ルウェ アン セコㇿ ラム ヤㇰネ、アヌン オルン シンナ カㇻ エアㇱカイ ペ アヌンコレ エアㇱカイ。
6. クㇽ ネ カトゥ、ネプキ エアㇱカイ ペ、アコサウヌ クス アコイタㇰ カトゥ、アコサウヌ クス アヌカㇻ ペ、アコサウヌ オカケ カトゥ アナㇰネ、インカルㇱクㇽ コンルスイ コㇿ アヌイェ ワ シンナ カㇻ エアㇱカイ。
## 3 エイワンケ ヘアㇱ ヒ ネワ イオマレ コード
1. タン チヘコテㇷ゚ オㇿ アナㇰネ、ID ネワ エシナ イタㇰ パテㇰ アエイワンケ ソモ ネ。 インカルㇱクㇽ アヌㇺケ ネン ネ ヒ アヌㇺケ クス アン アスルソㇱ ネワ イオマレ コード アエイワンケ エアㇱカイ。
2. イエイワンケクㇽ アナㇰネ、ヤイ オルン アウンコレ イオマレ コード、ネン ネ ヒ アヌㇺケ クス アン アスルソㇱ、ネワ ヤイ アエイワンケㇷ゚ オッタ アウカオ ペ ヤイ ネプキ ネ ワ ピㇼカノ コㇿ クニ ネ。
3. イエイワンケクㇽ アナㇰネ、ヤイ イオマレ コード ネワ ネン ネ ヒ アヌㇺケ クス アン アスルソㇱ オヤ クㇽ オルン コレ、アエソセ、ウココㇿ、アエラムヌ カ イテキ。
4. イオマレ コード アエラムヌ、オヤ クㇽ エイワンケ、アサㇰ、アウㇰ ネワ オヤ イㇰネ クス、イエイワンケクㇽ ネワ オヤ クㇽ エウタサ ウェン ペ アン ヤㇰカ、インカルㇱクㇽ アナㇰネ ネア ウェン ペ ヤイ ネプキ ネ ソモ コㇿ。
5. インカルㇱクㇽ アナㇰネ、ネン ネ ヒ アフナラ、ウェン イキ アエモㇱマ、アヌカㇻ ワ アオㇿセ、ネワ シㇱテㇺ アコセレ クス、エイワンケ アスルソㇱ ネワ アフン ヒ アスルソㇱ アウコテ ワ エイワンケ エアㇱカイ。
## 4 アコサウヌ シンナ カㇻ ヒ オㇿ アン イタㇰ
1. イヨロッ クㇽ アナㇰネ、シロシ アウウェカㇻパ シㇱテㇺ ピㇼカ カトゥ アウカオ ヒ イオッタ エトコ オマレ クニ ネ。 ヤイ コㇿ ペ セコㇿ ソモ ラム、アフナラ エアㇱカイ ヒ、スイ エイワンケ エアㇱカイ ヒ、アヌカㇻ エアㇱカイ ヒ、ウコシネ カトゥ ピㇼカ ヒ エトコ オマレ クニ ネ。
2. イヨロッ クㇽ アナㇰネ、ヤイ エラムㇷ゚キㇻ ヒ、ヤイ ウタㇻ ウコイタㇰ、イコㇿヌ イキ、イウタサ イタㇰ ネワ ウウェン イキ クス、シロシ ネワ Wiki シンナ カㇻ カ イテキ。
3. イヨロッ クㇽ アナㇰネ、アエラㇺペテㇰ ペ アオウペカレ、シロシ ウコアタㇺ、ウコテㇷ゚ アコセレ、ネワ アスルソㇱ アコオㇷ゚テ クス シンナ カㇻ クニ ネ。
4. インカルㇱクㇽ アナㇰネ、アコイタㇰ ペ、フㇱコ イキ カトゥ、シンナ カㇻ ペ ピㇼカ ヒ、イコイタㇰ エアㇱカイ ヒ、ネワ ネプキ エアㇱカイ ヒ アヌカㇻ ワ、アコサウヌ、アカオ、アエテㇰ、ネワ アウㇰ エアㇱカイ。
5. イヨロッ クㇽ ネ ヒ アナㇰネ、オウペカノ ネ ペ ソモ ネ。 インカルㇱクㇽ タン チヘコテㇷ゚ アコセレ クス ウンコレ ペ ネ ワ、ホシㇷ゚ アウㇰ エアㇱカイ ペ ネ。
## 5 イテキ アキ クニ ペ
イエイワンケクㇽ アナㇰネ、タプ アン イキ カトゥ イテキ キ。
1. イレンカ ネワ オピッタ クㇽ アコㇿ ピㇼカ カトゥ ウコㇻ ヒ アトゥイェ ペ。
2. ウェン イキ アエカスイ、ネワ ウェン イキ オルン ウコテ ペ。
3. アヌイェクㇽ オウペカノ、著作者人格権、商標権、肖像権、パブリシティ権、プライバシー権 ネワ オヤ クㇽ コㇿ オウペカノ アエアㇱカイ ペ アエトゥイェ ペ。
4. オヤ クㇽ アヌイェ ペ エセ イサㇺ ノ アサンケ、イレンカ ソモ エセ アップロード、アコピ、エセ イサㇺ ノ アコピ ペ、ウンコレ ソモ アン ウㇱケ オルン クㇽ アソンココレ、ネワ ネア イキ アオウペカレ、アサンケ、アエカスイ ペ。
5. ソンノ アン クㇽ オルン ウェン イタㇰ、イコヤイシㇻカ、ウタㇻ アシㇰヌカㇻ、イウェンテ、イウヌ イキ、イコペウㇰ、イレンカ ソモ エセ イキ、ネワ オヤ クㇽ イウェンテ ペ。
6. オヤ クㇽ コㇿ エシナ クニ ペ アスルソㇱ、アサンケ ソモ アン アスルソㇱ、エシナ クニ ペ アナㇰネ、ネア クㇽ エセ ソモ キ コㇿ アサンケ、アソンココレ、ウココㇿ、ネワ ネン ネ ヤッカ エラムアン エアㇱカイ カトゥ アヌカレ ペ。
7. ソモ ソンノ ネ アスルソㇱ、クㇽ エラムㇱカリ ネ アスルソㇱ、アスルソㇱ モトホ アオスラ ペ、アヌンコレ ソモ アン シロシ、ソモ ウコテ シロシ ポロンノ アオマレ、アウウェカㇻパ シㇱテㇺ アウェンテ、アフナラ アウェンテ ネワ オヤ ウェン イキ。
8. マルウェア、フィッシング、イコカンタマ ヒ、イコカンタマ アスㇽ アサンケ ペ、ウェン リダイレクト、イヤイキㇷ゚テ ソイ ウコテㇷ゚ ネワ イエイワンケクㇽ ネ ヤッカ インカルㇱクㇽ オルン ウェン ペ アカㇻ ペ。
9. タン チヘコテㇷ゚ コㇿ イレンカ エアㇱカイ ソモ ネ 政治 イウヌ イタㇰ、宗教 オルン イコタㇺ、モン クス アスㇽ アサンケ ヒ、ウコヤヤㇻ イタㇰ、ポロンノ アサンケ ウェン イタㇰ、シネ イタㇰ スイ スイ アサンケ ペ。
10. ポン クㇽ イカㇱヌ ヒ アウェンテ、児童性的搾取、イレンカ ソモ エセ 性的表現、ルイ ソモ ピㇼカ 性的表現 ネワ ルイ ウェン イキ ヒ アヌカレ ペ クス、オピッタ クㇽ ヌカㇻ ウㇱケ オルン ヤイトゥパレ イサㇺ ノ アサンケ ペ。
11. インカルㇱクㇽ、オヤ イエイワンケクㇽ、ソイ チヘコテㇷ゚ ネワ オヤ クㇽ オルン ポロ ネプキ、ウェン ペ、ウウェン カトゥ アカㇻ ペ。
12. エトコ タ アヌイェ イキ カトゥ アキ クス アモㇱマ、オヤ クㇽ オルン アキレ、ネワ ネア イキ エアㇱカイ クニ アカㇻ ペ。
13. オヤ イキ ネ ヤッカ、タン チヘコテㇷ゚ コㇿ イレンカ ネワ アプンノ アコセレ ヒ クス インカルㇱクㇽ ウェン セコㇿ ラム ペ。
## 6 アサンケペ、シロシ、Wiki ネワ オヤ ペ
1. イエイワンケクㇽ アナㇰネ、ヤイ アサンケ、シンナ カㇻ、アヌイェ、アソンココレ ネワ アキル アスルソㇱ クス、ネア ペ エイワンケ エアㇱカイ オウペカノ ネワ イレンカ エセ ヒ アン クニ ネ。
2. イエイワンケクㇽ アナㇰネ、ヤイ アサンケペ、シロシ アオマレ ヒ、Wiki アシンナ カㇻ ヒ、ウエペケレ イタㇰ、アヌイェ イタㇰ、ネワ ウコテ ヒ オピッタ クス ヤイ ネプキ ネ ワ アコㇿ クニ ネ。
3. イエイワンケクㇽ アナㇰネ、インカルㇱクㇽ オルン、タン チヘコテㇷ゚ アコセレ、アヌカレ、アコピ、アウカオ、アソンココレ、アウウェカㇻパ、オヤ クㇽ イタㇰ アエイワンケ ヒ、フㇱコ アン ペ アヌカレ、ホシピレ、オヤ ウㇱケ タ アウカオ ペ、シㇱテㇺ ウェン ヒ アコセレ、ネワ アスㇽ アサンケ ヒ クス コンルスイ パㇰノ、ヤイ アカㇻ アスルソㇱ モン アウㇰ ソモ キ ノ、オヤ クㇽ カ エイワンケ エアㇱカイ シネン パテㇰ ネ ソモ オウペカノ ウンコレ。
4. エトコ アン ウンコレ ヒ アナㇰネ、タン チヘコテㇷ゚ アコセレ クス コンルスイ パㇰノ パテㇰ ネ。 ヤイ アカㇻ ペ コㇿ オウペカノ インカルㇱクㇽ オルン アウンコレ ルウェ ソモ ネ。
5. インカルㇱクㇽ アナㇰネ、シロシ ウコシネ ヒ、アヌイェ カトゥ ウコシネ ヒ、エラㇺペテㇰ ペ アオウペカレ ヒ、モㇱマ レ ウコテ ヒ、ホシピレ ヒ ネワ オヤ クス、アサンケペ、シロシ、Wiki ネワ オヤ ペ シンナ カㇻ、ヌカㇻ ソモ エアㇱカイ カトゥ カㇻ、イサㇺカ、ウコアタㇺ、シンナ アㇻキㇻ、ネワ シンナ カㇻ エアイカㇷ゚ カㇻ エアㇱカイ。
## 7 ソイ ウコテㇷ゚ ネワ アオマレ ペ
1. タン チヘコテㇷ゚ オㇿ アナㇰネ、ソイ サイト オルン ウコテㇷ゚、ソイ ペ アオマレ ペ、ネワ ネア ペ コㇿ アスルソㇱ コㇿ アスルソㇱ アヌカレ エアㇱカイ。
2. ソイ ウコテㇷ゚ オㇿ アン オウペカノ、アエイワンケ イタㇰ、アヌカレ ウㇱケ、イサㇺカ カトゥ、アスㇽ アサンケ ペ、オㇱ アフナラ ヒ、Cookie ネワ オヤ カトゥ アナㇰネ、ネア ソイ チヘコテㇷ゚ コㇿ イタㇰ アヌ クニ ネ。
3. インカルㇱクㇽ アナㇰネ、ソイ ウコテㇷ゚ イレンカ エセ ヒ、アプン ヒ、ネイ パㇰノ アン ヒ、ソンノ ネ ヒ、ピㇼカ ヒ、エイワンケ エアㇱカイ ヒ、ネワ アスルソㇱ オピッタ アン ヒ ソンノ ネ セコㇿ ソモ イタㇰ。
4. ソイ オウペカノ コㇿ クㇽ アコイタㇰ、インカルㇱクㇽ ラム、イレンカ アヌ、ネワ アプンノ アコセレ クス、インカルㇱクㇽ ソイ ウコテㇷ゚、アオマレ ペ、ポン ノカ、ウエペケレ イタㇰ ネワ オヤ アヌカレ ペ エアㇱカイ ヒ アヌㇺケ、シンナ カㇻ、アエシナ カㇻ、ネワ イサㇺカ エアㇱカイ。
## 8 フㇱコ アン ペ、ホシピレ、イサㇺカ
1. タン チヘコテㇷ゚ オㇿ アナㇰネ、アコセレ、アヌカㇻ ワ アオㇿセ、ウェン イキ アエモㇱマ、ネワ イコイタㇰ エアㇱカイ クス、アサンケペ、シロシ、Wiki ネワ オヤ シンナ カㇻ フㇱコ アンペ アウカオ、アヌカレ、ネワ インカルㇱクㇽ パテㇰ ヌカㇻ エアㇱカイ。
2. イエイワンケクㇽ アナㇰネ、シネ スイ シンナ カㇻ ペ オカケ タ ホシピレ、アシンナ カㇻ、アエシナ カㇻ、ネワ イサㇺカ エアㇱカイ ヒ エトコ タ エラムアン クニ ネ。
3. イエイワンケクㇽ イサㇺカ クス コンルスイ ヤㇰカ、イレンカ、アコセレ、アヌカㇻ ワ アオㇿセ、ウエン カトゥ アオウペカレ、ネワ オヤ ウㇱケ タ アウカオ ペ クス コンルスイ ヤㇰネ、ナニ オピッタ イサㇺカ エアイカㇷ゚ カ アン。
4. インカルㇱクㇽ アナㇰネ、タン チヘコテㇷ゚ オピッタ ピㇼカノ アコセレ クス、エトコ タ ウエペケレ ヤㇰカ ウエペケレ ソモ キ ヤㇰカ、フㇱコ アンペ アヌカレ ウㇱケ、アウカオ パキ、ホシピレ イレンカ、ネワ イサㇺカ イレンカ アヌイェ ワ シンナ カㇻ エアㇱカイ。
## 9 エイワンケ アヌㇺケ、イヨロッ クㇽ アウㇰ、BAN
1. インカルㇱクㇽ アナㇰネ、イエイワンケクㇽ タプ アン シネ ペ ネ ヤッカ イキ セコㇿ ラム ヤㇰネ、エトコ タ ソンコ コレ ソモ キ ワ、ネワ ソンコ コレ オカケ タ、アサンケ ネワ シンナ カㇻ ヒ アヌㇺケ、イヨロッ クㇽ ネ ヒ アウㇰ、アサンケペ アエシナ カㇻ ネワ イサㇺカ、イオマレ コード エイワンケ エアイカㇷ゚ カㇻ、イエイワンケクㇽ BAN、IP BAN ネワ オヤ コンルスイ ペ アカㇻ エアㇱカイ。
- タン イタㇰ ソモ アヌ ヒ。
- タン チヘコテㇷ゚ コㇿ イレンカ ソモ エセ ワ、ネプキ アウェンテ、アㇻシ、ピㇼカ カトゥ アウェンテ ヒ。
- インカルㇱクㇽ アヌカㇻ、アシンナ カㇻ クニ イタㇰ、オケレ クニ イタㇰ オルン、ピㇼカ モト イサㇺ ノ ソモ イタサ ヒ。
- アヌイェ アスルソㇱ、アコイタㇰ ペ、ネワ ウエペケレ イタㇰ ソモ ソンノ ネ ヒ。
- アプンノ アン ヒ、イレンカ アヌ ヒ、ネワ タン チヘコテㇷ゚ アコセレ クス コンルスイ ヒ。
2. インカルㇱクㇽ アナㇰネ、ネア イキ コㇿ モト、イレンカ、アヌカㇻ ペ ネワ ヤイ ラム カトゥ オピッタ アヌカレ クニ ネ ソモ アン。
3. エイワンケ アヌㇺケ オカケ タ、ネワ イヨロッ クㇽ ネ ヒ アウㇰ オカケ タ ヤッカ、インカルㇱクㇽ コンルスイ ヤㇰネ、フㇱコ アンペ、ネプ アキ ヒ アヌイェ ペ、アコイタㇰ カンピ、ソンコ コレ ヒ カンピ ネワ オヤ アスルソㇱ アウカオ エアㇱカイ。
## 10 ポン クㇽ エイワンケ ヒ
1. インカルㇱクㇽ アナㇰネ、ポン クㇽ アプンノ アン クス、パㇰ ネ カトゥ アヌカレ ヒ アヌㇺケ、アヌカレ ル アヌㇺケ、アエシナ カㇻ、イサㇺカ、アコイタㇰ ペ アエテㇰ ネワ オヤ イキ カㇻ エアㇱカイ。
2. イエイワンケクㇽ アナㇰネ、ポン クㇽ ヌカㇻ エアㇱカイ オピッタ アヌカレ ウㇱケ オルン、ポン クㇽ クス ウェン ペ ヤイトゥパレ ソモ キ ノ アサンケ カ イテキ。
## 11 イコイタㇰ、ソンコ コレ ヒ、`イコイタㇰ ウㇱケ`
1. イエイワンケクㇽ アナㇰネ、タン チヘコテㇷ゚ アヌカレ イコイタㇰ、ソンコ コレ ヒ、ネワ `イコイタㇰ ウㇱケ` オルン ウコテㇷ゚ カリ、シㇱテㇺ ネプキ ウェン ヒ アスル、ウェン ペ アスル、イサㇺカ クニ イコイタㇰ ネワ オヤ ソンコ アサンケ エアㇱカイ。
2. インカルㇱクㇽ アナㇰネ、オピッタ イコイタㇰ オルン イタサ イタㇰ コレ クニ ネ ソモ アン。 イタサ イタㇰ コレ パキ、ネプ アカㇻ ヒ、ネワ ネコン アカㇻ ヒ ソンノ ネ セコㇿ ソモ イタㇰ。
## 12 インカルㇱクㇽ ヤイ ネプキ ネ ソモ コㇿ ヒ
1. インカルㇱクㇽ アナㇰネ、タン チヘコテㇷ゚ シネ イレンカ クス ピㇼカ ヒ、オピッタ アン ヒ、ソンノ ネ ヒ、ネイ パㇰノ アン ヒ、アプン ヒ、ウェン ペ イサㇺ ヒ、ネワ イエイワンケクㇽ コンルスイ ペ ネ ヒ ソンノ ネ セコㇿ ソモ イタㇰ。
2. ソイ ウコテㇷ゚、ソイ アオマレ ペ、オヤ クㇽ アサンケペ、イエイワンケクㇽ ウタㇻ ウウェン ヒ、ソンコ アソンココレ ヒ アウェン ヒ、アスルソㇱ イサㇺ ヒ、ソモ オウペカノ アウウェカㇻパ、ソモ オウペカノ ウコテ、ソモ オウペカノ アヌイェ、ホシピレ、ネプキ オケレ ヒ、ネワ イアヌ シンナ カㇻ ヒ クス ウェン ペ アン ヤㇰカ、インカルㇱクㇽ アナㇰネ ネア ウェン ペ ヤイ ネプキ ネ ソモ コㇿ。
3. タン チヘコテㇷ゚ アナㇰネ、エトコ タ ソンコ コレ ソモ キ ワ オケレ、アシンナ シンナ カㇻ、ネワ ポンノ パテㇰ ネ エアㇱカイ。
## 13 タン イタㇰ シンナ カㇻ ヒ
1. インカルㇱクㇽ アナㇰネ、イレンカ シンナ ネ ヒ、ネプキ アシンノ オマレ ヒ、ネプキ カトゥ シンナ カㇻ ヒ、アプンノ アン クス、イタㇰ アオウペカレ ヒ ネワ オヤ モト クス、タン イタㇰ シンナ カㇻ エアㇱカイ。
2. アシンノ シンナ アカㇻ タン イタㇰ アナㇰネ、タン チヘコテㇷ゚ オッタ アサンケ ヒ タ、ネワ インカルㇱクㇽ オヤ パキ アヌイェ ヤㇰネ ネア パキ タ、エイワンケ クス アン イタㇰ ネ。
3. タン イタㇰ シンナ アカㇻ オカケ タ タン チヘコテㇷ゚ エイワンケ クㇽ アナㇰネ、アシンノ シンナ アカㇻ イタㇰ エセ ルウェ ネ セコㇿ インカルㇱクㇽ ラム。
## 14 イレンカ ネワ 裁判所
1. タン イタㇰ ネワ タン チヘコテㇷ゚ アエイワンケ ヒ アナㇰネ、ニッポン イレンカ アヌ クニ ネ。
2. タン イタㇰ ネワ タン チヘコテㇷ゚ クス ウウェン イタㇰ ネ ヤッカ、インカルㇱクㇽ オカ ウㇱケ コㇿ 裁判所 アナㇰネ、ホシキ 裁判 オㇿ パテㇰ アウウェネウサㇻ ウㇱケ ネ セコㇿ アウコイタㇰ。 コㇿカ、イレンカ オッタ オヤ イアヌ アン ヤㇰネ、ネア イレンカ アヌ クニ ネ。
## オカケ イタㇰ
タン イタㇰ アナㇰネ、{lastUpdatedAt} ワノ アエイワンケ。
</article>
</MainArea>
+30
ファイルの表示
@@ -0,0 +1,30 @@
import PageHead from '@/components/common/PageHead'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { LocalisedDocument } from '@/mdx-components'
import type { ComponentType } from 'react'
const terms = import.meta.glob<ComponentType> ('./*.mdx', { eager: true, import: 'default' })
const TOSPage = () => {
const locale = useLocale ()
const { message } = useUITranslation ('navigation')
const Terms = terms[`./${ locale }.mdx`] ?? terms['./ja.mdx']
const language = terms[`./${ locale }.mdx`] != null ? locale : 'ja'
if (!Terms)
throw new Error ('TOSPage: ja.mdx not found')
return (
<>
<PageHead title={message ('terms')}/>
<LocalisedDocument language={language} className="flex-1">
<Terms/>
</LocalisedDocument>
</>)
}
export default TOSPage
+2 -8
ファイルの表示
@@ -1,17 +1,11 @@
import { Helmet } from 'react-helmet-async'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { dateString } from '@/lib/utils'
export const lastUpdatedAt = dateString ('2026-04-12', 'hour')
export const lastUpdatedAt = dateString ('2026-04-12', 'hour', 'ja')
<MainArea>
<Helmet>
<title>{`利用規約 | ${ SITE_TITLE }`}</title>
</Helmet>
<article className="prose dark:prose-invert mx-auto p-4">
<article lang="ja" className="prose dark:prose-invert mx-auto p-4">
# 利用規約
最終更新日: {lastUpdatedAt}
+1 -1
ファイルの表示
@@ -63,7 +63,7 @@ describe ('DeerjikistDetailPage', () => {
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith (
'/tags/7/deerjikists',
[{ platform: null, code: 'abc' }],
[{ platform: null, code: 'abc' }], { locale: 'ja' },
)
})
expect (await screen.findByText ('プラットフォームを入力してください.')).toBeInTheDocument ()
+18 -11
ファイルの表示
@@ -2,11 +2,14 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import TagLink from '@/components/TagLink'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import PageHead from '@/components/common/PageHead'
import { toast } from '@/components/ui/use-toast'
import { PLATFORM_NAMES, PLATFORMS } from '@/consts'
import { apiPut } from '@/lib/api'
@@ -24,18 +27,20 @@ type DeerjikistFormField =
const DeerjikistDetailPage: FC = () => {
const { message, label } = useUITranslation ('shared')
const locale = useLocale ()
const { id } = useParams ()
const tagId = String (id ?? '')
const tagKey = tagsKeys.deerjikists (tagId)
const tagKey = tagsKeys.deerjikists (tagId, locale)
const { data: qData, isLoading: loading } =
useQuery ({ queryKey: tagKey, queryFn: () => fetchDeerjikistsByTag (tagId) })
useQuery ({ queryKey: tagKey, queryFn: () => fetchDeerjikistsByTag (tagId, locale) })
const tag = qData?.tag
const deerjikists = useMemo (() => qData?.deerjikists ?? [], [qData])
const [data, setData] =
useState<(Omit<Deerjikist, 'platform'> & { platform: Platform | null })[]> ([])
const [disabled, setDisabled] = useState (true)
useState<(Omit<Deerjikist, 'platform'> & { platform: Platform | null })[]> (deerjikists)
const [disabled, setDisabled] = useState (tag == null)
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<DeerjikistFormField> ()
@@ -49,15 +54,16 @@ const DeerjikistDetailPage: FC = () => {
{
setDisabled (true)
setData (await apiPut<Deerjikist[]> (`/tags/${ id }/deerjikists`, data))
setData (await apiPut<Deerjikist[]> (`/tags/${ id }/deerjikists`, data, { locale }))
qc.invalidateQueries ({ queryKey: tagsKeys.root })
toast ({ description: '更新しました.' })
toast ({ description: label ('messages.updated') })
}
catch (e)
{
applyValidationError (e)
toast ({ title: '更新失敗', description: '入力内容を確認してください.' })
toast ({ title: label ('messages.updateFailed'),
description: label ('messages.checkInputContent') })
}
finally
{
@@ -78,7 +84,8 @@ const DeerjikistDetailPage: FC = () => {
return (
<MainArea>
{(loading || !(tag)) ? 'Loading...' : (
<PageHead title={tag?.name ?? message ('common:categories.deerjikist')}/>
{(loading || !(tag)) ? label ('common:loading') : (
<div className="max-w-xl">
<PageTitle>
<TagLink tag={tag} withWiki={false} withCount={false}/>
@@ -104,7 +111,7 @@ const DeerjikistDetailPage: FC = () => {
{/* プラットフォーム */}
<FormField
label="プラットフォーム"
label={label ('labels.platform')}
messages={fieldErrors[`deerjikists${ i }Platform`]}>
{({ describedBy, invalid }) => (
<select
@@ -129,7 +136,7 @@ const DeerjikistDetailPage: FC = () => {
{/* コード */}
<FormField
label="コード"
label={label ('labels.code')}
messages={fieldErrors[`deerjikists${ i }Code`]}>
{({ describedBy, invalid }) => (
<input
@@ -164,7 +171,7 @@ const DeerjikistDetailPage: FC = () => {
(disabled
? 'text-gray-300 bg-gray-500'
: 'text-white bg-blue-500'))}>
更新
{label ('actions.update')}
</button>
</div>
</form>
+16 -13
ファイルの表示
@@ -8,7 +8,7 @@ import App from '@/App'
import { helpFixture } from '@/test/helpFixtures'
vi.hoisted (() => {
window.history.replaceState (null, '', '/help/changes')
window.history.replaceState (null, '', '/ja/help/changes')
})
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
@@ -21,7 +21,10 @@ vi.mock ('@/lib/useClientBehaviourSettings', () => ({
describe ('Help global edition history', () => {
beforeEach (() => helpFixture.reset ())
beforeEach (() => {
helpFixture.reset ()
vi.mocked (apiGet).mockResolvedValue ({ locales: [{ code: 'ja', name: '日本語' }] })
})
it ('routes /help/changes to published editions in date and numeric order', async () => {
const client = new QueryClient ({ defaultOptions: { queries: { retry: false } } })
@@ -31,12 +34,12 @@ describe ('Help global edition history', () => {
</QueryClientProvider>)
const links = await screen.findAllByRole ('link')
const editions = links.map (link => link.getAttribute ('href'))
.filter (href => href?.startsWith ('/help/') && href.includes ('edition='))
.filter (href => href?.startsWith ('/ja/help/') && href.includes ('edition='))
expect (editions).toEqual ([
`/help/${encodeURIComponent ('設定 手順')}?edition=2026-10-03`,
'/help/guide?edition=2026-10-02.10', '/help/guide?edition=2026-10-02.3',
'/help/guide?edition=2026-10-02.2', '/help/guide?edition=2026-10-02',
'/help/guide?edition=2026-10-01'])
`/ja/help/${encodeURIComponent ('設定 手順')}?edition=2026-10-03`,
'/ja/help/guide?edition=2026-10-02.10', '/ja/help/guide?edition=2026-10-02.3',
'/ja/help/guide?edition=2026-10-02.2', '/ja/help/guide?edition=2026-10-02',
'/ja/help/guide?edition=2026-10-01'])
expect (container).toHaveTextContent ('旧ホーム')
expect (screen.getAllByRole ('columnheader').map (cell => cell.textContent))
.toEqual (['差分', 'タイトル', '有効開始年月日'])
@@ -45,11 +48,11 @@ describe ('Help global edition history', () => {
expect (container.textContent).not.toMatch (/\d{1,2}:\d{2}/)
expect (screen.getAllByRole ('link', { name: '差分' }).map (link =>
link.getAttribute ('href'))).toEqual ([
'/help/guide/diff?from=2026-10-02.3&to=2026-10-02.10',
'/help/guide/diff?from=2026-10-02.2&to=2026-10-02.3',
'/help/guide/diff?from=2026-10-02&to=2026-10-02.2',
'/help/guide/diff?from=2026-10-01&to=2026-10-02'])
expect (apiGet).not.toHaveBeenCalled ()
expect (window.location.pathname).toBe ('/help/changes')
'/ja/help/guide/diff?from=2026-10-02.3&to=2026-10-02.10',
'/ja/help/guide/diff?from=2026-10-02.2&to=2026-10-02.3',
'/ja/help/guide/diff?from=2026-10-02&to=2026-10-02.2',
'/ja/help/guide/diff?from=2026-10-01&to=2026-10-02'])
expect (apiGet).toHaveBeenCalledWith ('/locales')
expect (window.location.pathname).toBe ('/ja/help/changes')
})
})
+21 -17
ファイルの表示
@@ -1,22 +1,27 @@
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { Helmet } from 'react-helmet-async'
import { useSearchParams } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { getHelpChanges, helpEditionDate } from '@/lib/help'
import { localeLanguage } from '@/lib/locales'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { helpKeys } from '@/lib/queryKeys'
import { LIST_LIMIT_OPTIONS } from '@/lib/settings'
import { dateString } from '@/lib/utils'
import { localisedDateString } from '@/lib/utils'
import type { FC } from 'react'
const HelpChangesPage: FC = () => {
const { message, label } = useUITranslation ('help')
const locale = useLocale ()
const language = localeLanguage (locale)
const [searchParams, setSearchParams] = useSearchParams ()
const identifier = searchParams.get ('identifier') ?? ''
const pageValue = searchParams.get ('page') ?? '1'
@@ -27,8 +32,9 @@ const HelpChangesPage: FC = () => {
const limit = LIST_LIMIT_OPTIONS.find (
value => String (value) === searchParams.get ('limit')) ?? 20
const { data: changes = [], isLoading, error } = useQuery ({
queryKey: helpKeys.changes ('ja'),
queryFn: () => getHelpChanges ('ja'),
queryKey: helpKeys.changes (language),
queryFn: () => getHelpChanges (language),
select: entries => entries.filter (entry => entry.language === language),
staleTime: Infinity })
const results = useMemo (() => identifier
@@ -47,13 +53,11 @@ const HelpChangesPage: FC = () => {
return (
<MainArea className="min-w-0 max-w-full">
<Helmet>
<title>{`ヘルプ改定履歴 | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('history')}/>
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle>ヘルプ改定履歴</PageTitle>
<PageTitle>{label ('history')}</PageTitle>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>件数</span>
<span>{label ('common:countLabel')}</span>
<select value={limit} onChange={ev => handleLimit (ev.target.value)}
className="rounded border border-border bg-background px-2 py-1 text-sm
text-foreground">
@@ -64,9 +68,9 @@ const HelpChangesPage: FC = () => {
</div>
{isLoading
? <p className="mt-4">読み込み中...</p>
? <p className="mt-4">{label ('shared:messages.loading')}</p>
: (error
? <p className="mt-4 text-red-600" role="alert">{error.message}</p>
? <p className="mt-4 text-red-600" role="alert">{label ('common:fetchError')}</p>
: (
<div className="mt-4">
<div className="min-w-0 max-w-full overflow-x-auto">
@@ -74,9 +78,9 @@ const HelpChangesPage: FC = () => {
border-collapse">
<thead className="border-b-2 border-black dark:border-white">
<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 ('shared:actions.diff')}</th>
<th className="p-2 text-left">{label ('shared:labels.title')}</th>
<th className="p-2 text-left">{label ('effectiveSince')}</th>
</tr>
</thead>
<tbody>
@@ -94,7 +98,7 @@ const HelpChangesPage: FC = () => {
<td className="p-2">
{entry.pred != null && (
<PrefetchLink to={diffTo}>
差分
{label ('shared:actions.diff')}
</PrefetchLink>)}
</td>
<td className="p-2">
@@ -103,7 +107,7 @@ const HelpChangesPage: FC = () => {
</PrefetchLink>
</td>
<td className="p-2">
{updatedAt != null && dateString (updatedAt, 'hour')}
{updatedAt != null && localisedDateString (updatedAt, locale, 'hour')}
</td>
</tr>)
})}
+13 -13
ファイルの表示
@@ -17,19 +17,19 @@ const Navigation = () => {
return (
<>
<output data-testid="location">{location.pathname}{location.search}</output>
<button onClick={() => navigate ('/help/設定%20手順')}>switch article</button>
<button onClick={() => navigate ('/help/guide?edition=2026-10-02.3')}>
<button onClick={() => navigate ('/ja/help/設定%20手順')}>switch article</button>
<button onClick={() => navigate ('/ja/help/guide?edition=2026-10-02.3')}>
switch edition
</button>
</>)
}
const renderDetail = (route = '/help/guide') => renderWithProviders (
const renderDetail = (route = '/ja/help/guide') => renderWithProviders (
<>
<Navigation/>
<Routes>
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
<Route path="/ja/help/:identifier" element={<HelpDetailPage/>}/>
</Routes>
</>, { route })
@@ -39,7 +39,7 @@ describe ('Help details', () => {
it.each (['2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10']) (
'displays a date without inventing a time for edition %s', async edition => {
const { container } = renderDetail (`/help/guide?edition=${ edition }`)
const { container } = renderDetail (`/ja/help/guide?edition=${ edition }`)
await screen.findByRole ('heading')
expect (container).toHaveTextContent ('10月2日')
expect (container).not.toHaveTextContent ('Invalid Date')
@@ -61,31 +61,31 @@ describe ('Help details', () => {
})
it ('uses the historical H1 in both page and document titles', async () => {
renderDetail ('/help/guide?edition=2026-10-01')
renderDetail ('/ja/help/guide?edition=2026-10-01')
expect (await screen.findByRole ('heading', { name: '旧ホーム' })).toBeInTheDocument ()
expect (screen.getByText ('過去限定本文')).toBeInTheDocument ()
await waitFor (() => expect (document.title).toContain ('旧ホーム ヘルプ'))
expect (screen.getByText ('(最古)')).toBeInTheDocument ()
expect (screen.getByRole ('link', { name: '新 >' }))
.toHaveAttribute ('href', '/help/guide?edition=2026-10-02')
.toHaveAttribute ('href', '/ja/help/guide?edition=2026-10-02')
})
it ('navigates .2, .3 and .10 in numeric edition order', async () => {
renderDetail ('/help/guide?edition=2026-10-02.3')
renderDetail ('/ja/help/guide?edition=2026-10-02.3')
await screen.findByRole ('heading', { name: 'ホーム第三版' })
expect (screen.getByRole ('link', { name: '< 古' }))
.toHaveAttribute ('href', '/help/guide?edition=2026-10-02.2')
.toHaveAttribute ('href', '/ja/help/guide?edition=2026-10-02.2')
const next = screen.getByRole ('link', { name: '新 >' })
expect (next).toHaveAttribute ('href', '/help/guide?edition=2026-10-02.10')
expect (next).toHaveAttribute ('href', '/ja/help/guide?edition=2026-10-02.10')
fireEvent.click (next)
await screen.findByRole ('heading', { name: 'ホーム第十版' })
expect (screen.getByText ('(最新)')).toBeInTheDocument ()
expect (screen.queryByText ('第三版本文')).not.toBeInTheDocument ()
})
it.each (['/help/missing', '/help/guide?edition=missing',
'/help/guide?edition=2026-02-30', '/help/guide?edition=2026-10-02.2.extra',
'/help/guide?edition=2026-10-02.11']) (
it.each (['/ja/help/missing', '/ja/help/guide?edition=missing',
'/ja/help/guide?edition=2026-02-30', '/ja/help/guide?edition=2026-10-02.2.extra',
'/ja/help/guide?edition=2026-10-02.11']) (
'shows NotFound without redirecting %s', async route => {
renderDetail (route)
expect (await screen.findByText ('404')).toBeInTheDocument ()
+39 -19
ファイルの表示
@@ -1,37 +1,44 @@
import { useQuery } from '@tanstack/react-query'
import { Helmet } from 'react-helmet-async'
import { useLocation, useParams } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { getHelpArticle,
getHelpEditionContent,
helpEditionDate,
parseHelpEdition } from '@/lib/help'
parseHelpEdition,
resolveHelpLanguage } from '@/lib/help'
import { localeLanguage } from '@/lib/locales'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { dateString } from '@/lib/utils'
import { helpKeys } from '@/lib/queryKeys'
import { currentRenderContext } from '@/lib/renderContext'
import { LocalisedDocument } from '@/mdx-components'
import NotFound from '@/pages/NotFound'
import type { FC } from 'react'
const HelpDetailPage: FC = () => {
const { message, label } = useUITranslation ('help')
const locale = useLocale ()
const { identifier = '' } = useParams ()
const location = useLocation ()
// Locale 対応が入るまでは ja 固定
const language = 'ja'
const { data: article, isLoading: articleLoading } = useQuery ({
queryKey: helpKeys.article (identifier),
queryFn: () => getHelpArticle (identifier),
staleTime: Infinity })
const requestedLanguage = localeLanguage (locale)
const language = resolveHelpLanguage (article, requestedLanguage)
const query = new URLSearchParams (location.search)
const requestedEdition = query.get ('edition')
const languageMeta = article?.languages[language]
const languageMeta = language == null ? null : article?.languages[language]
const edition = requestedEdition ?? languageMeta?.currentEdition
const editionInfo = parseHelpEdition (edition ?? '')
@@ -48,8 +55,8 @@ const HelpDetailPage: FC = () => {
const { data: current, isSuccess, isError } = useQuery ({
enabled: edition != null && editionInfo != null && editions.includes (edition),
queryKey: helpKeys.edition (identifier, language, edition ?? ''),
queryFn: () => getHelpEditionContent (identifier, language, edition ?? ''),
queryKey: helpKeys.edition (identifier, language ?? requestedLanguage, edition ?? ''),
queryFn: () => getHelpEditionContent (identifier, language ?? requestedLanguage, edition ?? ''),
staleTime: Infinity })
if (!articleLoading
@@ -64,12 +71,19 @@ const HelpDetailPage: FC = () => {
return <NotFound/>
const Content = current?.content
const origin = currentRenderContext ()?.origin
const canonical = origin == null ? undefined : new URL (
`/${locale}/help/${encodeURIComponent (identifier)}`, origin).href
const description = current?.body.replace (/!?(\[([^\]]*)\])\([^)]*\)/g, '$2')
.replace (/[#*`<>]/g, '')
.replace (/\s+/g, language === 'ain' ? '\u3000' : ' ').trim ().slice (0, 180)
return (
<MainArea>
<Helmet>
<title>{current?.title || identifier} ヘルプ | {SITE_TITLE}</title>
</Helmet>
<MainArea className="min-w-0">
<PageHead title={message ('articleTitle', { title: current?.title || identifier })}
canonical={canonical}
description={description == null ? undefined : {
text: description, language: language === 'ain' ? 'ain-Kana' : language ?? 'ja' }}/>
{requestedEdition && (
<div className="text-sm flex gap-3 items-center justify-center
@@ -78,9 +92,9 @@ const HelpDetailPage: FC = () => {
? (
<PrefetchLink
to={`/help/${ encodeURIComponent (identifier) }?edition=${ pred }`}>
&lt; 古
{label ('shared:actions.old')}
</PrefetchLink>)
: '(最古)'}
: label ('shared:actions.oldest')}
<span>
{editionDate != null && dateString (editionDate, 'hour')}
@@ -91,13 +105,19 @@ const HelpDetailPage: FC = () => {
? (
<PrefetchLink
to={`/help/${ encodeURIComponent (identifier) }?edition=${ succ }`}>
新 &gt;
{label ('shared:actions.new')}
</PrefetchLink>)
: '(最新)'}
: label ('shared:actions.latest')}
</div>)}
<article className="prose dark:prose-invert mx-auto p-4">
{Content ? <Content/> : <div>Loading...</div>}
<article lang={language ?? requestedLanguage}
className="prose dark:prose-invert mx-auto min-w-0 p-4">
{Content
? (
<LocalisedDocument language={language ?? requestedLanguage}>
<Content/>
</LocalisedDocument>)
: <div>{label ('common:loading')}</div>}
</article>
</MainArea>)
}
+21 -12
ファイルの表示
@@ -1,12 +1,14 @@
import { useQuery } from '@tanstack/react-query'
import { Helmet } from 'react-helmet-async'
import { useParams, useSearchParams } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { getHelpDiff } from '@/lib/help'
import PageHead from '@/components/common/PageHead'
import { getHelpArticle, getHelpDiff, resolveHelpLanguage } from '@/lib/help'
import { localeLanguage } from '@/lib/locales'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { helpKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
import NotFound from '@/pages/NotFound'
@@ -17,20 +19,29 @@ const markers = { added: '+ ', removed: '- ', unchanged: ' ' }
const HelpDiffPage: FC = () => {
const { message, label } = useUITranslation ('help')
const locale = useLocale ()
const { identifier = '' } = useParams ()
const [searchParams] = useSearchParams ()
const from = searchParams.get ('from') ?? ''
const to = searchParams.get ('to') ?? ''
const { data: article } = useQuery ({
queryKey: helpKeys.article (identifier),
queryFn: () => getHelpArticle (identifier),
staleTime: Infinity })
const requestedLanguage = localeLanguage (locale)
const language = resolveHelpLanguage (article, requestedLanguage)
const validParams = Boolean (identifier && from && to)
&& searchParams.getAll ('from').length === 1
&& searchParams.getAll ('to').length === 1
const { data: diff, isLoading, isError } = useQuery ({
enabled: validParams,
queryKey: helpKeys.diff (identifier, 'ja', from, to),
queryFn: () => getHelpDiff (identifier, 'ja', from, to),
enabled: validParams && language != null,
queryKey: helpKeys.diff (identifier, requestedLanguage, from, to),
queryFn: () => getHelpDiff (identifier, requestedLanguage, from, to),
staleTime: Infinity })
if (!(validParams) || isError || (!(isLoading) && diff == null))
if (!(validParams) || (article != null && language == null)
|| isError || (!(isLoading) && diff == null))
return <NotFound/>
const title = `${ diff?.to.title ?? identifier } (${ from } → ${ to })`
@@ -38,9 +49,7 @@ const HelpDiffPage: FC = () => {
return (
<MainArea className="min-w-0 max-w-full">
<Helmet>
<title>{`ヘルプ 差分: ${ title } | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('diffTitle', { title })}/>
<PageTitle>{title}</PageTitle>
{diff
@@ -55,7 +64,7 @@ const HelpDiffPage: FC = () => {
{diff.to.title} ({to})
</PrefetchLink>
</div>
<pre className="mt-4 overflow-x-auto whitespace-pre">
<pre lang={requestedLanguage} className="mt-4 overflow-x-auto whitespace-pre">
<code>
{diff.lines.map ((line, index) => {
const colour = cn (
@@ -73,7 +82,7 @@ const HelpDiffPage: FC = () => {
</code>
</pre>
</>)
: <div>Loading...</div>}
: <div>{label ('common:loading')}</div>}
</MainArea>)
}
+6 -6
ファイルの表示
@@ -14,9 +14,9 @@ describe ('Help search', () => {
it ('initially lists each current article once with encoded detail links', async () => {
renderWithProviders (<HelpListPage/>, { route: '/help' })
expect (await screen.findByRole ('link', { name: 'ホーム第十版' }))
.toHaveAttribute ('href', '/help/guide')
.toHaveAttribute ('href', '/ja/help/guide')
expect (screen.getByRole ('link', { name: '設定' }))
.toHaveAttribute ('href', `/help/${encodeURIComponent ('設定 手順')}`)
.toHaveAttribute ('href', `/ja/help/${encodeURIComponent ('設定 手順')}`)
expect (screen.getByText ('2 件')).toBeInTheDocument ()
expect (screen.queryByText ('旧ホーム')).not.toBeInTheDocument ()
expect (screen.queryByText ('未公開ホーム')).not.toBeInTheDocument ()
@@ -82,7 +82,7 @@ describe ('Help search', () => {
renderWithProviders (<HelpListPage/>, { route: `/help?${ params }` })
await screen.findByText (`${ found ? 1 : 0 } 件`)
if (found)
expect (screen.getByRole ('link', { name: title })).toHaveAttribute ('href', '/help/guide')
expect (screen.getByRole ('link', { name: title })).toHaveAttribute ('href', '/ja/help/guide')
else
expect (screen.queryByRole ('link', { name: title })).not.toBeInTheDocument ()
expect (entry).toEqual ({ identifier: 'guide', edition: '2026-10-02.10',
@@ -99,7 +99,7 @@ describe ('Help search', () => {
route: `/help?order=title:${ direction }` })
await screen.findByText ('2 件')
const expected = direction === 'asc'
? ['/help/beta', '/help/alpha'] : ['/help/alpha', '/help/beta']
? ['/ja/help/beta', '/ja/help/alpha'] : ['/ja/help/alpha', '/ja/help/beta']
expect (Array.from (container.querySelectorAll ('tbody a'), link =>
link.getAttribute ('href'))).toEqual (expected)
})
@@ -113,7 +113,7 @@ describe ('Help search', () => {
route: `/help?order=title:${ direction }` })
await screen.findByText ('2 件')
expect (Array.from (container.querySelectorAll ('tbody a'), link =>
link.getAttribute ('href'))).toEqual (['/help/alpha', '/help/beta'])
link.getAttribute ('href'))).toEqual (['/ja/help/alpha', '/ja/help/beta'])
})
it.each (['asc', 'desc']) ('ignores title case before identifier ties in order %s',
@@ -125,6 +125,6 @@ describe ('Help search', () => {
route: `/help?order=title:${ direction }` })
await screen.findByText ('2 件')
expect (Array.from (container.querySelectorAll ('tbody a'), link =>
link.getAttribute ('href'))).toEqual (['/help/alpha', '/help/beta'])
link.getAttribute ('href'))).toEqual (['/ja/help/alpha', '/ja/help/beta'])
})
})
+24 -19
ファイルの表示
@@ -1,6 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useSearchParams } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
@@ -9,12 +8,15 @@ import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { compareHelpEditions,
compareHelpTitles,
getHelpSearchEntries,
helpEditionDate,
normaliseHelpSearchText } from '@/lib/help'
import { localeLanguage } from '@/lib/locales'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { helpKeys } from '@/lib/queryKeys'
import { LIST_LIMIT_OPTIONS } from '@/lib/settings'
import { dateString, inputClass } from '@/lib/utils'
@@ -30,6 +32,8 @@ const defaultDirection: Record<HelpOrderField, 'asc' | 'desc'> = {
const HelpSearchPage: FC = () => {
const { message, label } = useUITranslation ('help')
const language = localeLanguage (useLocale ())
const [searchParams, setSearchParams] = useSearchParams ()
const pageValue = searchParams.get ('page') ?? '1'
const pageNumber = Number (pageValue)
@@ -49,8 +53,9 @@ const HelpSearchPage: FC = () => {
const [title, setTitle] = useState (conditions.title)
const [text, setText] = useState (conditions.text)
const { data: entries = [], isLoading: loading, error } = useQuery ({
queryKey: helpKeys.index ('ja'),
queryFn: () => getHelpSearchEntries ('ja'),
queryKey: helpKeys.index (language),
queryFn: () => getHelpSearchEntries (language),
select: entries => entries.filter (entry => entry.language === language),
staleTime: Infinity })
useEffect (() => {
@@ -107,15 +112,13 @@ const HelpSearchPage: FC = () => {
return (
<MainArea className="min-w-0 max-w-full">
<Helmet>
<title>{`ヘルプ | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('title')}/>
<div className="max-w-xl">
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle>ヘルプ検索</PageTitle>
<PageTitle>{label ('search')}</PageTitle>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>件数</span>
<span>{label ('common:countLabel')}</span>
<select value={limit} onChange={ev => handleLimit (ev.target.value)}
className="rounded border border-border bg-background px-2 py-1 text-sm
text-foreground">
@@ -125,19 +128,19 @@ const HelpSearchPage: FC = () => {
</label>
</div>
<form onSubmit={handleSearch} className="space-y-2">
<FormField label="タイトル">
<FormField label={label ('shared:labels.title')}>
{({ invalid }) => (
<input type="text"
aria-label="タイトル"
aria-label={message ('shared:labels.title').text}
value={title}
onChange={e => setTitle (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="内容">
<FormField label={label ('shared:labels.content')}>
{({ invalid }) => (
<input type="text"
aria-label="内容"
aria-label={message ('shared:labels.content').text}
value={text}
onChange={e => setText (e.target.value)}
className={inputClass (invalid)}/>)}
@@ -146,19 +149,21 @@ const HelpSearchPage: FC = () => {
<div className="py-3">
<button type="submit"
className="bg-blue-500 text-white px-4 py-2 rounded">
検索
{label ('common:search')}
</button>
</div>
</form>
</div>
{loading
? <p className="mt-4">読み込み中...</p>
? <p className="mt-4">{label ('shared:messages.loading')}</p>
: (error
? <p className="mt-4 text-red-600" role="alert">{error.message}</p>
: (
<div className="mt-4">
<p className="mb-2 text-sm" aria-live="polite">{results.length} 件</p>
<p className="mb-2 text-sm" aria-live="polite">
{label ('shared:labels.resultsCount', { count: results.length })}
</p>
<div className="min-w-0 max-w-full overflow-x-auto">
<table className="table-auto w-full min-w-[640px] whitespace-nowrap
border-collapse text-gray-900 dark:text-gray-100">
@@ -167,14 +172,14 @@ const HelpSearchPage: FC = () => {
<th className="p-2 text-left">
<SortHeader<HelpOrderField>
by="title"
label="タイトル"
label={label ('shared:labels.title')}
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left">
<SortHeader<HelpOrderField>
by="updated_at"
label="有効開始年月日"
label={label ('effectiveSince')}
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
@@ -203,7 +208,7 @@ const HelpSearchPage: FC = () => {
</table>
</div>
{totalPages > 0 && <Pagination page={page} totalPages={totalPages}/>}
{results.length === 0 && <p className="mt-4">該当する記事がありません.</p>}
{results.length === 0 && <p className="mt-4">{label ('empty')}</p>}
</div>))}
</MainArea>)
}
+7 -4
ファイルの表示
@@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { textContentOf } from '@/test/text'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
import { buildMaterial, buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
@@ -59,7 +60,7 @@ describe ('MaterialDetailPage', () => {
renderPage ()
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith ('/materials/8')
expect (api.apiGet).toHaveBeenCalledWith ('/materials/8', { locale: 'ja' })
})
expect (await screen.findByAltText ('素材タグ')).toHaveAttribute ('src', 'image.png')
})
@@ -82,13 +83,15 @@ describe ('MaterialDetailPage', () => {
fireEvent.click (screen.getByRole ('button', { name: '更新' }))
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith ('/materials/8', expect.any (FormData))
expect (api.apiPut).toHaveBeenCalledWith (
'/materials/8', expect.any (FormData), { locale: 'ja' })
})
const formData = api.apiPut.mock.calls[0]?.[1] as FormData
expect (formData.get ('tag')).toBe ('new')
expect (formData.get ('url')).toBe ('https://example.com/ref')
expect (formData.get ('export_paths[legacy_drive]')).toBe ('素材/new.png')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '更新成功!' })
expect (toastApi.toast).toHaveBeenCalledTimes (1)
expect (textContentOf (toastApi.toast.mock.calls[0][0].title)).toBe ('更新成功!')
})
it ('shows not found when material API returns 404', async () => {
@@ -97,7 +100,7 @@ describe ('MaterialDetailPage', () => {
renderPage ()
expect (await screen.findByText ('素材が見つかりませんでした.')).toBeInTheDocument ()
expect (await screen.findByText ('404')).toBeInTheDocument ()
})
it ('shows an error for non-404 material API failures', async () => {
+43 -33
ファイルの表示
@@ -1,8 +1,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom'
import { useLocale } from '@/lib/useLocale'
import TagLink from '@/components/TagLink'
import WikiBody from '@/components/WikiBody'
import FieldError from '@/components/common/FieldError'
@@ -13,14 +13,17 @@ import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import {
fetchMaterial,
updateMaterial,
} from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { localeLanguage } from '@/lib/locales'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import { useUITranslation } from '@/lib/useUITranslation'
import NotFound from '@/pages/NotFound'
import type { FC } from 'react'
@@ -28,23 +31,26 @@ type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
const MaterialDetailPage: FC = () => {
const locale = useLocale ()
const { message, label } = useUITranslation ('materials')
const { id } = useParams ()
const qc = useQueryClient ()
const { data: material, isError, isLoading } = useQuery ({
queryKey: materialsKeys.show (id ?? '', locale),
queryFn: () => fetchMaterial (id ?? '', locale),
enabled: id != null })
const [exportPath, setExportPath] = useState ('')
const [exportPath, setExportPath] = useState (material?.exportPaths.legacyDrive ?? '')
const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState ('')
const [tag, setTag] = useState ('')
const [url, setURL] = useState ('')
const [filePreview, setFilePreview] = useState (
material?.contentType != null ? material.file ?? '' : '')
const [tag, setTag] = useState (material?.tag?.name ?? '')
const [url, setURL] = useState (material?.url ?? '')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<MaterialFormField> ()
const { data: material, isError, isLoading } = useQuery ({
queryKey: materialsKeys.show (id ?? ''),
queryFn: () => fetchMaterial (id ?? ''),
enabled: id != null})
const materialTitle = material
? material.tag?.name ?? `素材 #${ material.id }`
? material.tag?.name ?? message ('unnamed', { id: material.id }).text
: ''
useEffect (() => {
@@ -76,16 +82,16 @@ const MaterialDetailPage: FC = () => {
formData.append ('url', url)
formData.append ('export_paths[legacy_drive]', exportPath)
return await updateMaterial (id ?? '', formData)
return await updateMaterial (id ?? '', formData, locale)
},
onSuccess: async data => {
qc.setQueryData (materialsKeys.show (id ?? ''), data)
qc.setQueryData (materialsKeys.show (id ?? '', locale), data)
await invalidateMaterialQueries ()
toast ({ title: '更新成功!' })
toast ({ title: label ('updated') })
},
onError: error => {
applyValidationError (error)
toast ({ title: '更新失敗……', description: '入力を見直してください.' })
toast ({ title: label ('updateFailed'), description: label ('shared:messages.reviewInput') })
}})
const handleSubmit = () => {
@@ -93,19 +99,20 @@ const MaterialDetailPage: FC = () => {
updateMutation.mutate ()
}
if (!isLoading && !isError && material == null)
return <NotFound/>
return (
<MainArea>
{material && (
<Helmet>
<title>{`${ materialTitle } 素材照会 | ${ SITE_TITLE }`}</title>
</Helmet>)}
<PageHead title={material
? message ('detailTitle', { name: materialTitle }) : message ('title')}/>
{isLoading ? 'Loading...' : isError ? (
{isLoading ? label ('common:loading') : isError ? (
<p className="text-red-600 dark:text-red-300">
素材の取得に失敗しました.
{label ('detailError')}
</p>) : material == null ? (
<p className="text-stone-700 dark:text-stone-300">
素材が見つかりませんでした.
{label ('missing')}
</p>) : (
<>
<PageTitle>
@@ -115,7 +122,7 @@ const MaterialDetailPage: FC = () => {
tag={material.tag}
withWiki={false}
withCount={false}/>)
: materialTitle}
: label ('unnamed', { id: material.id })}
</PageTitle>
{(material.file && material.contentType) && (
@@ -128,17 +135,18 @@ const MaterialDetailPage: FC = () => {
<TabGroup>
{material.tag && (
<Tab name="Wiki">
<Tab name={label ('navigation:wiki')}>
<WikiBody
title={material.tag.name}
language={localeLanguage (locale)}
body={material.wikiPageBody ?? undefined}/>
</Tab>)}
<Tab name="編輯">
<Tab name={label ('shared:actions.edit')}>
<div className="max-w-wl space-y-4 pt-2">
<FieldError messages={baseErrors}/>
<FormField label="タグ" messages={fieldErrors.tag}>
<FormField label={label ('navigation:tags')} messages={fieldErrors.tag}>
{({ describedBy, invalid }) => (
<TagInput
describedBy={describedBy}
@@ -147,7 +155,7 @@ const MaterialDetailPage: FC = () => {
setValue={setTag}/>)}
</FormField>
<FormField label="ファイル" messages={fieldErrors.file}>
<FormField label={label ('file')} messages={fieldErrors.file}>
{({ describedBy, invalid }) => (
<>
<input
@@ -165,7 +173,8 @@ const MaterialDetailPage: FC = () => {
(/image\/.*/.test (file.type) && (
<img
src={filePreview}
alt="preview"
alt={message ('preview').text}
lang={message ('preview').language}
className="mt-2 max-h-48 rounded border"/>))
|| (/video\/.*/.test (file.type) && (
<video
@@ -179,12 +188,12 @@ const MaterialDetailPage: FC = () => {
className="mt-2 max-h-48"/>))
|| (
<p className="text-red-600 dark:text-red-400">
その形式のファイルには対応していません.
{label ('unsupportedFile')}
</p>))}
</>)}
</FormField>
<FormField label="参考 URL" messages={fieldErrors.url}>
<FormField label={label ('referenceUrl')} messages={fieldErrors.url}>
{({ describedBy, invalid }) => (
<input
type="url"
@@ -195,13 +204,14 @@ const MaterialDetailPage: FC = () => {
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="ZIP 出力パス" messages={fieldErrors.exportPaths}>
<FormField label={label ('exportPath')} messages={fieldErrors.exportPaths}>
{({ describedBy, invalid }) => (
<input
type="text"
value={exportPath}
onChange={e => setExportPath (e.target.value)}
placeholder="伊地知ニジカ/表情/泣き.png"
placeholder={message ('exportPlaceholder').text}
lang={message ('exportPlaceholder').language}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
@@ -213,7 +223,7 @@ const MaterialDetailPage: FC = () => {
className="rounded bg-blue-600 px-4 py-2 text-white
disabled:bg-gray-400"
disabled={updateMutation.isPending}>
更新
{label ('shared:actions.update')}
</Button>
</div>
</div>
+36 -29
ファイルの表示
@@ -1,28 +1,31 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import PrefetchLink from '@/components/PrefetchLink'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchMaterialChanges } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { dateString, inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import { localisedDateString, inputClass } from '@/lib/utils'
import type { FC, FormEvent } from 'react'
const MaterialHistoryPage: FC = () => {
const { message, label } = useUITranslation ('history')
const locale = useLocale ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const location = useLocation ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const query = new URLSearchParams (location.search)
const materialId = query.get ('material_id') ?? ''
@@ -47,13 +50,13 @@ const MaterialHistoryPage: FC = () => {
...(tag && { tag }),
...(eventType && { eventType }),
page,
limit }),
limit }, locale),
queryFn: () => fetchMaterialChanges ({
...(materialId && { materialId }),
...(tag && { tag }),
...(eventType && { eventType }),
page,
limit }) })
limit }, locale) })
const versions = data?.versions ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
@@ -80,12 +83,10 @@ const MaterialHistoryPage: FC = () => {
return (
<MainArea>
<Helmet>
<title>{`素材履歴 | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('materialTitle')}/>
<div className="space-y-5">
<PageTitle>素材履歴</PageTitle>
<PageTitle>{label ('materialTitle')}</PageTitle>
<form
onSubmit={handleSearch}
@@ -93,20 +94,24 @@ const MaterialHistoryPage: FC = () => {
text-stone-900 dark:border-stone-700 dark:bg-stone-900
dark:text-stone-100">
<div className="space-y-3">
<FormField label="素材 ID">
<FormField label={label ('materialId')}>
{({ invalid }) => (
<input
type="text"
value={materialIdInput}
aria-label={message ('materialId').text}
lang={message ('materialId').language}
onChange={e => setMaterialIdInput (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="タグ名">
<FormField label={label ('tagName')}>
{({ invalid }) => (
<input
type="text"
value={tagInput}
aria-label={message ('tagName').text}
lang={message ('tagName').language}
onChange={e => setTagInput (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
@@ -117,16 +122,16 @@ const MaterialHistoryPage: FC = () => {
className="mt-4 rounded-full border border-stone-300 bg-white px-4 py-2
text-sm text-stone-900 hover:bg-stone-100 dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800">
絞り込む
{label ('filter')}
</button>
</form>
{isLoading
? 'Loading...'
: isError
? label ('common:loading')
: (isError
? (
<p className="text-red-600 dark:text-red-300">
素材履歴の取得に失敗しました.
<p className="text-red-600 dark:text-red-300" role="alert">
{label ('materialError')}
</p>)
: (
<>
@@ -145,14 +150,14 @@ const MaterialHistoryPage: FC = () => {
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th className="p-2 text-left">日時</th>
<th className="p-2 text-left">素材 ID</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">URL</th>
<th className="p-2 text-left">export_path</th>
<th className="p-2 text-left">操作者</th>
<th className="p-2 text-left">{label ('wiki:dateTime')}</th>
<th className="p-2 text-left">{label ('materialId')}</th>
<th className="p-2 text-left">{label ('version')}</th>
<th className="p-2 text-left">{label ('tagName')}</th>
<th className="p-2 text-left">{label ('filename')}</th>
<th className="p-2 text-left">{label ('shared:url')}</th>
<th className="p-2 text-left">{label ('exportPath')}</th>
<th className="p-2 text-left">{label ('actor')}</th>
</tr>
</thead>
@@ -161,7 +166,9 @@ const MaterialHistoryPage: FC = () => {
<tr
key={version.id}
className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">{dateString (version.createdAt)}</td>
<td className="p-2">
{localisedDateString (version.createdAt, locale)}
</td>
<td className="p-2">
<PrefetchLink to={`/materials/${ version.materialId }`}>
#{version.materialId}
@@ -177,7 +184,7 @@ const MaterialHistoryPage: FC = () => {
<td className="p-2">
{version.createdByUser
? version.createdByUser.name || `#${ version.createdByUser.id }`
: 'bot 操作'}
: label ('bot')}
</td>
</tr>))}
</tbody>
@@ -185,7 +192,7 @@ const MaterialHistoryPage: FC = () => {
</div>
<Pagination page={page} totalPages={totalPages}/>
</>)}
</>))}
</div>
</MainArea>)
}
+10 -9
ファイルの表示
@@ -23,7 +23,7 @@ describe ('MaterialListPage', () => {
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/materials',
{ params: {
{ locale: 'ja', params: {
tag_state: 'all',
media_kind: 'all',
sort: 'created_at',
@@ -53,8 +53,9 @@ describe ('MaterialListPage', () => {
renderWithProviders (<MaterialListPage/>, { route: '/materials' })
expect ((await screen.findByText ('素材タグ')).closest ('a'))
.toHaveAttribute ('href', '/materials/8')
expect (screen.getByText (/画像 \/ 令和8年1月2日/)).toBeInTheDocument ()
.toHaveAttribute ('href', '/ja/materials/8')
expect (screen.getByText ('令和8年1月2日(金) 12:04:05').parentElement)
.toHaveTextContent ('画像 / 令和8年1月2日')
})
it ('submits list filters as material index params', async () => {
@@ -93,7 +94,7 @@ describe ('MaterialListPage', () => {
expect (await screen.findByRole (
'link',
{ name: '素材検索トップへ戻る' },
)).toHaveAttribute ('href', '/materials?material_filter=any')
)).toHaveAttribute ('href', '/ja/materials?material_filter=any')
})
it ('uses list view for detailed material rows', async () => {
@@ -113,7 +114,7 @@ describe ('MaterialListPage', () => {
expect (await screen.findByRole ('link', { name: '詳細素材' })).toHaveAttribute (
'href',
'/materials/9',
'/ja/materials/9',
)
expect (screen.getByText ('サイズ:')).toBeInTheDocument ()
expect (screen.getByText ('2.0 KB')).toBeInTheDocument ()
@@ -153,11 +154,11 @@ describe ('MaterialListPage', () => {
expect (screen.getByRole ('link', { name: '泣き' })).toHaveAttribute (
'href',
'/materials?tag_id=30&include_descendants=1&group_by=parent_tag&material_filter=present',
)
'/ja/materials?tag_id=30&include_descendants=1'
+ '&group_by=parent_tag&material_filter=present')
expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute (
'href',
'/materials?material_filter=present',
'/ja/materials?material_filter=present',
)
})
@@ -174,7 +175,7 @@ describe ('MaterialListPage', () => {
expect (await screen.findByText ('選択中タグが見つかりません.')).toBeInTheDocument ()
expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute (
'href',
'/materials?material_filter=present',
'/ja/materials?material_filter=present',
)
})
})
+153 -128
ファイルの表示
@@ -1,19 +1,21 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import nikumaru from '@/assets/fonts/nikumaru.otf'
import PrefetchLink from '@/components/PrefetchLink'
import LocalisedText from '@/components/LocalisedText'
import DateTimeField from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { fetchMaterials, parseMaterialFilter } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { cn, dateString, inputClass } from '@/lib/utils'
import { cn, localisedDateString, inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, FormEvent } from 'react'
@@ -29,28 +31,28 @@ import type { FetchMaterialsParams,
MaterialIndexView } from '@/types'
const MEDIA_KIND_LABELS: Record<Material['mediaKind'], string> = {
image: '画像',
video: '動画',
audio: '音声',
file_other: 'その他ファイル',
url_only: 'URL のみ'}
image: 'media.image',
video: 'media.video',
audio: 'media.audio',
file_other: 'media.file_other',
url_only: 'media.url_only' }
const MEDIA_FILTER_LABELS: Record<MaterialIndexMediaKind, string> = {
all: 'すべて',
image: '画像',
video: '動画',
audio: '音声',
file_other: 'その他ファイル',
url_only: '外部リンクのみ'}
all: 'mediaFilter.all',
image: 'mediaFilter.image',
video: 'mediaFilter.video',
audio: 'mediaFilter.audio',
file_other: 'mediaFilter.file_other',
url_only: 'mediaFilter.url_only' }
const SORT_LABELS: Record<MaterialIndexSort, string> = {
created_at: '作成日時',
updated_at: '更新日時',
tag_name: 'タグ名',
media_kind: '種類',
file_byte_size: '容量',
version_no: '版',
id: 'Id.'}
created_at: 'sort.created_at',
updated_at: 'sort.updated_at',
tag_name: 'sort.tag_name',
media_kind: 'sort.media_kind',
file_byte_size: 'sort.file_byte_size',
version_no: 'sort.version_no',
id: 'sort.id' }
const setIf = (qs: URLSearchParams, key: string, value: string | null) => {
@@ -77,10 +79,6 @@ const fileSizeText = (bytes: number | null): string => {
}
const materialTitle = (material: Material): string =>
material.tag?.name ?? `素材 #${ material.id }`
const groupedTagPath = (
tagId: number,
materialFilter: MaterialFilter,
@@ -131,87 +129,106 @@ const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
</div>)
const MaterialCard: FC<{ material: Material }> = ({ material }) => (
<article className="w-[180px] justify-self-center">
<PrefetchLink to={`/materials/${ material.id }`} className="block">
<MaterialThumb material={material}/>
<div className="mt-2 w-[180px]">
<p className="truncate text-sm font-medium text-stone-900 dark:text-stone-100">
{materialTitle (material)}
</p>
<p className="truncate text-xs text-stone-600 dark:text-stone-300">
{MEDIA_KIND_LABELS[material.mediaKind]} / {dateString (material.createdAt)}
</p>
</div>
</PrefetchLink>
</article>)
const MaterialListItem: FC<{ material: Material }> = ({ material }) => (
<article
className="rounded-lg border border-stone-200 bg-white p-3 text-stone-900
shadow-sm dark:border-stone-700 dark:bg-stone-900 dark:text-stone-100">
<div className="flex gap-3">
<MaterialThumb material={material}/>
<div className="min-w-0 flex-1 space-y-2">
<div>
<PrefetchLink
to={`/materials/${ material.id }`}
className="font-medium text-sky-700 underline underline-offset-2
dark:text-sky-300">
{materialTitle (material)}
</PrefetchLink>
const MaterialCard: FC<{ material: Material }> = ({ material }) => {
const locale = useLocale ()
const { label } = useUITranslation ('materials')
return (
<article className="w-[180px] justify-self-center">
<PrefetchLink to={`/materials/${ material.id }`} className="block">
<MaterialThumb material={material}/>
<div className="mt-2 w-[180px]">
<p className="truncate text-sm font-medium text-stone-900 dark:text-stone-100">
{material.tag?.name ?? label ('unnamed', { id: material.id })}
</p>
<p className="text-xs text-stone-600 dark:text-stone-300">
{label (MEDIA_KIND_LABELS[material.mediaKind])} /{' '}
<LocalisedText language={locale}>
{localisedDateString (material.createdAt, locale)}
</LocalisedText>
</p>
</div>
<dl className="space-y-1 text-sm text-stone-600 dark:text-stone-300">
</PrefetchLink>
</article>)
}
const MaterialListItem: FC<{ material: Material }> = ({ material }) => {
const locale = useLocale ()
const { label } = useUITranslation ('materials')
return (
<article
className="rounded-lg border border-stone-200 bg-white p-3 text-stone-900
shadow-sm dark:border-stone-700 dark:bg-stone-900 dark:text-stone-100">
<div className="flex flex-wrap gap-3">
<MaterialThumb material={material}/>
<div className="min-w-0 flex-1 space-y-2">
<div>
<dt className="inline">種類: </dt>
<dd className="inline">{MEDIA_KIND_LABELS[material.mediaKind]}</dd>
<PrefetchLink
to={`/materials/${ material.id }`}
className="font-medium text-sky-700 underline underline-offset-2
dark:text-sky-300">
{material.tag?.name ?? label ('unnamed', { id: material.id })}
</PrefetchLink>
</div>
{material.fileByteSize != null && (
<div>
<dt className="inline">サイズ: </dt>
<dd className="inline">{fileSizeText (material.fileByteSize)}</dd>
</div>)}
{material.url && (
<div>
<dt className="inline">URL: </dt>
<dd className="inline break-all">{material.url}</dd>
</div>)}
<div>
<dt className="inline">作成: </dt>
<dd className="inline">{dateString (material.createdAt)}</dd>
</div>
</dl>
<dl className="space-y-1 text-sm text-stone-600 dark:text-stone-300">
<div>
<dt className="inline">{label ('kindPrefix')}</dt>
<dd className="inline">{label (MEDIA_KIND_LABELS[material.mediaKind])}</dd>
</div>
{material.fileByteSize != null && (
<div>
<dt className="inline">{label ('sizePrefix')}</dt>
<dd className="inline">{fileSizeText (material.fileByteSize)}</dd>
</div>)}
{material.url && (
<div>
<dt className="inline">{label ('urlPrefix')}</dt>
<dd className="inline break-all">{material.url}</dd>
</div>)}
<div>
<dt className="inline">{label ('createdPrefix')}</dt>
<dd className="inline">
<LocalisedText language={locale}>
{localisedDateString (material.createdAt, locale)}
</LocalisedText>
</dd>
</div>
</dl>
</div>
</div>
</div>
</article>)
</article>)
}
const GroupHeading: FC<{
count: number
materialFilter: MaterialFilter
title: string
tagId: number
}> = ({ count, materialFilter, title, tagId }) => (
<div className="flex items-center gap-2 border-b border-stone-200 pb-2
dark:border-stone-700">
<PrefetchLink
to={groupedTagPath (tagId, materialFilter)}
className="font-medium text-sky-700 underline underline-offset-2
dark:text-sky-300 w-full">
{title}
</PrefetchLink>
<div className="text-right w-auto">
<span className="text-nowrap text-sm text-stone-600 dark:text-stone-300">
{count} 件
</span>
</div>
</div>)
tagId: number }> = ({ count, materialFilter, title, tagId }) => {
const { label } = useUITranslation ('materials')
return (
<div className="flex flex-wrap items-center gap-2 border-b border-stone-200 pb-2
dark:border-stone-700">
<PrefetchLink
to={groupedTagPath (tagId, materialFilter)}
className="min-w-0 flex-1 font-medium text-sky-700 underline underline-offset-2
dark:text-sky-300">
{title}
</PrefetchLink>
<div className="text-right w-auto">
<span className="text-sm text-stone-600 dark:text-stone-300">
{label ('shared:labels.resultsCount', { count })}
</span>
</div>
</div>)
}
const MaterialListPage: FC = () => {
const locale = useLocale ()
const { message, label } = useUITranslation ('materials')
const location = useLocation ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const query = useMemo (() => new URLSearchParams (location.search), [location.search])
const materialFilter = parseMaterialFilter (query.get ('material_filter'), 'present')
@@ -277,8 +294,8 @@ const MaterialListPage: FC = () => {
page,
limit}
const { data, isLoading, isError } = useQuery ({
queryKey: materialsKeys.index (keys),
queryFn: () => fetchMaterials (keys)})
queryKey: materialsKeys.index (keys, locale),
queryFn: () => fetchMaterials (keys, locale)})
const tagScope = data?.tagScope ?? null
const materials = data?.materials ?? []
const groups = data?.groups ?? []
@@ -385,7 +402,7 @@ const MaterialListPage: FC = () => {
<section className="space-y-3">
<div className="border-b border-stone-200 pb-2 dark:border-stone-700">
<span className="font-medium text-stone-900 dark:text-stone-100">
その他
{label ('other')}
</span>
</div>
{renderMaterialCollection (ungroupedMaterials)}
@@ -394,7 +411,7 @@ const MaterialListPage: FC = () => {
return (
<MainArea>
<Helmet>
<PageHead title={message ('title')}>
<style>
{`
@font-face
@@ -403,12 +420,11 @@ const MaterialListPage: FC = () => {
src: url(${ nikumaru }) format('opentype');
}`}
</style>
<title>{`素材管理 | ${ SITE_TITLE }`}</title>
</Helmet>
</PageHead>
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle className="my-auto">素材管理</PageTitle>
<PageTitle className="my-auto">{label ('title')}</PageTitle>
<div className="flex flex-wrap gap-2">
{/* TODO: 局所出力を可能にする */}
{/* <a
@@ -429,25 +445,25 @@ const MaterialListPage: FC = () => {
dark:border-sky-800 dark:bg-sky-950 dark:text-sky-100">
<span>
{tagScope.tag.name}
{tagScope.includeDescendants ? ' 配下の素材を表示中' : ' の素材を表示中'}
{label (tagScope.includeDescendants ? 'descendantScope' : 'tagScope')}
</span>
<PrefetchLink
to={clearedTagSelectionPath (location.search, materialFilter)}
className="font-medium underline underline-offset-2
text-sky-700 dark:text-sky-300">
タグ選択を解除
{label ('clearTag')}
</PrefetchLink>
</div>)}
{(tagId != null && tagScope == null) && (
<div className="flex flex-wrap items-center gap-3 rounded-lg border
border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900
dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100">
<span>選択中タグが見つかりません.</span>
<span>{label ('tagMissing')}</span>
<PrefetchLink
to={clearedTagSelectionPath (location.search, materialFilter)}
className="font-medium underline underline-offset-2
text-amber-800 dark:text-amber-200">
タグ選択を解除
{label ('clearTag')}
</PrefetchLink>
</div>)}
@@ -455,7 +471,7 @@ const MaterialListPage: FC = () => {
<PrefetchLink
to={`/materials?material_filter=${ materialFilter }`}
className="text-sm text-sky-700 underline underline-offset-2 dark:text-sky-300">
素材検索トップへ戻る
{label ('searchTop')}
</PrefetchLink>)}
<form
@@ -464,44 +480,49 @@ const MaterialListPage: FC = () => {
text-stone-900 dark:border-stone-700 dark:bg-stone-900
dark:text-stone-100">
<div className="space-y-3">
<FormField label="検索">
<FormField label={label ('common:search')}>
{({ invalid }) => (
<input
type="search"
value={q}
onChange={e => setQ (e.target.value)}
placeholder="タグ名 / URL / ファイル名"
placeholder={message ('searchPlaceholder').text}
lang={message ('searchPlaceholder').language}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="タグ状態">
<FormField label={label ('tagState')}>
{({ invalid }) => (
<select
value={tagStateInput}
onChange={e => setTagStateInput (
e.target.value as MaterialIndexTagState)}
className={inputClass (invalid)}>
<option value="all">すべて</option>
<option value="tagged">タグあり</option>
<option value="all" lang={message ('mediaFilter.all').language}>
{message ('mediaFilter.all').text}
</option>
<option value="tagged" lang={message ('tagged').language}>
{message ('tagged').text}
</option>
<option value="untagged"></option>
</select>)}
</FormField>
<FormField label="メディア">
<FormField label={label ('mediaLabel')}>
{({ invalid }) => (
<select
value={mediaKindInput}
onChange={e => setMediaKindInput (
e.target.value as MaterialIndexMediaKind)}
className={inputClass (invalid)}>
{Object.entries (MEDIA_FILTER_LABELS).map (([value, label]) => (
<option key={value} value={value}>
{label}
{Object.entries (MEDIA_FILTER_LABELS).map (([value, key]) => (
<option key={value} value={value} lang={message (key).language}>
{message (key).text}
</option>))}
</select>)}
</FormField>
<FormField label="作成日時">
<FormField label={label ('sort.created_at')}>
{() => (
<div className="flex flex-wrap items-center gap-2">
<DateTimeField value={createdFrom ?? undefined} onChange={setCreatedFrom}/>
@@ -510,7 +531,7 @@ const MaterialListPage: FC = () => {
</div>)}
</FormField>
<FormField label="更新日時">
<FormField label={label ('sort.updated_at')}>
{() => (
<div className="flex flex-wrap items-center gap-2">
<DateTimeField value={updatedFrom ?? undefined} onChange={setUpdatedFrom}/>
@@ -525,7 +546,7 @@ const MaterialListPage: FC = () => {
type="submit"
className="rounded bg-sky-600 px-4 py-2 text-white hover:bg-sky-700
dark:bg-sky-500 dark:text-stone-950 dark:hover:bg-sky-400">
検索
{label ('common:search')}
</button>
</div>
</form>
@@ -544,7 +565,7 @@ const MaterialListPage: FC = () => {
: [
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'])}>
アイコン
{label ('cardView')}
</button>
<button
type="button"
@@ -558,7 +579,7 @@ const MaterialListPage: FC = () => {
: [
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'])}>
詳細
{label ('listView')}
</button>
</div>
@@ -567,34 +588,38 @@ const MaterialListPage: FC = () => {
value={sort}
onChange={e => updateQuery ({ sort: e.target.value, page: '1' })}
className={inputClass (false, 'w-auto')}>
{Object.entries (SORT_LABELS).map (([value, label]) => (
<option key={value} value={value}>
{label}
{Object.entries (SORT_LABELS).map (([value, key]) => (
<option key={value} value={value} lang={message (key).language}>
{message (key).text}
</option>))}
</select>
<select
value={direction}
onChange={e => updateQuery ({ direction: e.target.value, page: '1' })}
className={inputClass (false, 'w-auto')}>
<option value="desc">降順</option>
<option value="asc">昇順</option>
<option value="desc" lang={message ('descending').language}>
{message ('descending').text}
</option>
<option value="asc" lang={message ('ascending').language}>
{message ('ascending').text}
</option>
</select>
</div>
</div>
{isLoading && <p>Loading...</p>}
{isLoading && <p>{label ('common:loading')}</p>}
{isError && (
<p className="text-red-600 dark:text-red-300">素材一覧の取得に失敗しました.</p>)}
<p className="text-red-600 dark:text-red-300">{label ('listError')}</p>)}
{(!isLoading && !isError && materials.length === 0) && (
<p>
素材はありません。
{label ('empty')}
{(tagScope && ['character', 'material'].includes (tagScope.tag.category)) && (
<>
<PrefetchLink
to={materialNewPath (tagScope.tag.name, location.pathname + location.search)}
className="font-medium underline underline-offset-2
text-sky-700 dark:text-sky-300">
追加してください
{label ('pleaseAdd')}
</PrefetchLink>。
</>)}
</p>)}
+14 -11
ファイルの表示
@@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Route, Routes, useLocation } from 'react-router-dom'
import { textContentOf } from '@/test/text'
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
import { renderWithProviders } from '@/test/render'
@@ -33,7 +34,7 @@ describe ('MaterialNewPage', () => {
it ('initializes tag from query and submits form data', async () => {
api.apiPost.mockResolvedValueOnce ({})
renderWithProviders (<MaterialNewPage/>, { route: '/materials/new?tag=%E8%99%B9%E5%A4%8F' })
renderWithProviders (<MaterialNewPage/>, { route: '/ja/materials/new?tag=%E8%99%B9%E5%A4%8F' })
expect (screen.getAllByRole ('textbox')[0]).toHaveValue ('虹夏')
fireEvent.change (screen.getAllByRole ('textbox')[1], {
@@ -42,12 +43,14 @@ describe ('MaterialNewPage', () => {
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
await waitFor (() => {
expect (api.apiPost).toHaveBeenCalledWith ('/materials', expect.any (FormData))
expect (api.apiPost).toHaveBeenCalledWith (
'/materials', expect.any (FormData), { locale: 'ja' })
})
const formData = api.apiPost.mock.calls[0]?.[1] as FormData
expect (formData.get ('tag')).toBe ('虹夏')
expect (formData.get ('url')).toBe ('https://example.com/ref')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '送信成功!' })
expect (toastApi.toast).toHaveBeenCalledTimes (1)
expect (textContentOf (toastApi.toast.mock.calls[0][0].title)).toBe ('送信成功!')
})
it ('shows validation errors for file and url fields', async () => {
@@ -81,12 +84,12 @@ describe ('MaterialNewPage', () => {
renderWithProviders (
<Routes>
<Route path="/materials/new" element={<MaterialNewPage/>}/>
<Route path="/materials" element={<LocationView/>}/>
<Route path="/ja/materials/new" element={<MaterialNewPage/>}/>
<Route path="/ja/materials" element={<LocationView/>}/>
</Routes>,
{
route:
'/materials/new?tag=%E8%99%B9%E5%A4%8F'
'/ja/materials/new?tag=%E8%99%B9%E5%A4%8F'
+ '&return_to=%2Fmaterials%3Ftag_id%3D20',
},
)
@@ -96,7 +99,7 @@ describe ('MaterialNewPage', () => {
})
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('/materials?tag_id=20')).toBeInTheDocument ()
expect (await screen.findByText ('/ja/materials?tag_id=20')).toBeInTheDocument ()
})
it ('ignores unsafe return_to values after successful creation', async () => {
@@ -104,12 +107,12 @@ describe ('MaterialNewPage', () => {
renderWithProviders (
<Routes>
<Route path="/materials/new" element={<MaterialNewPage/>}/>
<Route path="/materials" element={<LocationView/>}/>
<Route path="/ja/materials/new" element={<MaterialNewPage/>}/>
<Route path="/ja/materials" element={<LocationView/>}/>
</Routes>,
{
route:
'/materials/new?tag=%E8%99%B9%E5%A4%8F'
'/ja/materials/new?tag=%E8%99%B9%E5%A4%8F'
+ '&return_to=https%3A%2F%2Fexample.com%2Fevil',
},
)
@@ -119,6 +122,6 @@ describe ('MaterialNewPage', () => {
})
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('/materials')).toBeInTheDocument ()
expect (await screen.findByText ('/ja/materials')).toBeInTheDocument ()
})
})
+25 -20
ファイルの表示
@@ -1,8 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form'
import FormField from '@/components/common/FormField'
@@ -11,11 +11,13 @@ import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { createMaterial } from '@/lib/materials'
import { unlocalisedPath } from '@/lib/locales'
import { materialsKeys } from '@/lib/queryKeys'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react'
@@ -23,14 +25,17 @@ type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
const MaterialNewPage: FC = () => {
const locale = useLocale ()
const { message, label } = useUITranslation ('materials')
const qc = useQueryClient ()
const location = useLocation ()
const query = new URLSearchParams (location.search)
const tagQuery = query.get ('tag') ?? ''
const returnToQuery = query.get ('return_to') ?? ''
const safeReturnTo = returnToQuery.startsWith ('/materials') ? returnToQuery : ''
const safeReturnTo =
unlocalisedPath (returnToQuery).startsWith ('/materials') ? returnToQuery : ''
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState ('')
@@ -51,16 +56,16 @@ const MaterialNewPage: FC = () => {
formData.append ('url', url)
formData.append ('export_paths[legacy_drive]', exportPath)
return await createMaterial (formData)
return await createMaterial (formData, locale)
},
onSuccess: async () => {
await qc.invalidateQueries ({ queryKey: materialsKeys.root })
toast ({ title: '送信成功!' })
toast ({ title: label ('sent') })
navigate (safeReturnTo || '/materials')
},
onError: error => {
applyValidationError (error)
toast ({ title: '送信失敗……', description: '入力を見直してください.' })
toast ({ title: label ('sendFailed'), description: label ('shared:messages.reviewInput') })
}})
const handleSubmit = () => {
@@ -70,16 +75,14 @@ const MaterialNewPage: FC = () => {
return (
<MainArea>
<Helmet>
<title>{`素材追加 | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('newTitle')}/>
<Form>
<PageTitle>素材追加</PageTitle>
<PageTitle>{label ('newTitle')}</PageTitle>
<FieldError messages={baseErrors}/>
{/* タグ */}
<FormField label="タグ" messages={fieldErrors.tag}>
<FormField label={label ('navigation:tags')} messages={fieldErrors.tag}>
{({ describedBy, invalid }) => (
<TagInput
describedBy={describedBy}
@@ -89,7 +92,7 @@ const MaterialNewPage: FC = () => {
</FormField>
{/* ファイル */}
<FormField label="ファイル" messages={fieldErrors.file}>
<FormField label={label ('file')} messages={fieldErrors.file}>
{({ describedBy, invalid }) => (
<>
<input
@@ -106,7 +109,8 @@ const MaterialNewPage: FC = () => {
(/image\/.*/.test (file.type) && (
<img
src={filePreview}
alt="preview"
alt={message ('preview').text}
lang={message ('preview').language}
className="mt-2 max-h-48 rounded border"/>))
|| (/video\/.*/.test (file.type) && (
<video
@@ -120,13 +124,13 @@ const MaterialNewPage: FC = () => {
className="mt-2 max-h-48"/>))
|| (
<p className="text-red-600 dark:text-red-400">
その形式のファイルには対応していません.
{label ('unsupportedFile')}
</p>))}
</>)}
</FormField>
{/* 参考 URL */}
<FormField label="参考 URL" messages={fieldErrors.url}>
<FormField label={label ('referenceUrl')} messages={fieldErrors.url}>
{({ describedBy, invalid }) => (
<input
type="url"
@@ -138,14 +142,15 @@ const MaterialNewPage: FC = () => {
</FormField>
<FormField
label="ZIP 出力パス"
label={label ('exportPath')}
messages={fieldErrors.exportPaths}>
{({ describedBy, invalid }) => (
<input
type="text"
value={exportPath}
onChange={e => setExportPath (e.target.value)}
placeholder="伊地知ニジカ/表情/泣き.png"
placeholder={message ('exportPlaceholder').text}
lang={message ('exportPlaceholder').language}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
@@ -156,7 +161,7 @@ const MaterialNewPage: FC = () => {
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
disabled={createMutation.isPending}>
追加
{label ('shared:actions.add')}
</Button>
</Form>
</MainArea>)
+43 -50
ファイルの表示
@@ -1,28 +1,29 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocale } from '@/lib/useLocale'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { createMaterialSyncSuppression, fetchMaterialSyncSuppressions } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils'
import { localisedDateString, inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, FormEvent } from 'react'
import type { MaterialSyncSuppressionSourceKind } from '@/types'
const SOURCE_KIND_LABELS: Record<MaterialSyncSuppressionSourceKind, string> = {
uri: 'URI',
google_drive_path: 'Google Drive ファイル',
google_drive_path_prefix: 'Google Drive フォルダ',
google_drive_file: 'Google Drive ファイル Id.',
legacy_drive_path: '汎用ファイル',
legacy_drive_path_prefix: '汎用フォルダ' }
uri: 'suppressions.sources.uri',
google_drive_path: 'suppressions.sources.google_drive_path',
google_drive_path_prefix: 'suppressions.sources.google_drive_path_prefix',
google_drive_file: 'suppressions.sources.google_drive_file',
legacy_drive_path: 'suppressions.sources.legacy_drive_path',
legacy_drive_path_prefix: 'suppressions.sources.legacy_drive_path_prefix' }
const REASONS = [
'copyright_high_risk',
@@ -36,22 +37,9 @@ const REASONS = [
type MaterialSyncSuppressionReason = typeof REASONS[number]
const REASON_NAMES: Record<MaterialSyncSuppressionReason, string> = {
['copyright_high_risk']: '著作権への懸念',
['copyright_takedown']: '著作者からの申出',
['adult_or_sensitive']: '成人向け',
['personal_information']: '個人情報',
['malware_or_dangerous_file']: '危険なソフトウェア',
['duplicate_or_low_quality']: '重複',
['source_owner_request']: '同期元管理者からの申出',
['other']: 'その他' } as const
const reasonName = (reason: string): string =>
REASON_NAMES[reason as MaterialSyncSuppressionReason] ?? reason
const MaterialSyncSuppressionsPage: FC = () => {
const locale = useLocale ()
const { message, label } = useUITranslation ('materials')
const qc = useQueryClient ()
const [sourceKind, setSourceKind] =
useState<MaterialSyncSuppressionSourceKind> ('uri')
@@ -61,8 +49,8 @@ const MaterialSyncSuppressionsPage: FC = () => {
const [reason, setReason] = useState<MaterialSyncSuppressionReason> (REASONS[0])
const { data, isError, isLoading } = useQuery ({
queryKey: materialsKeys.suppressions (),
queryFn: fetchMaterialSyncSuppressions})
queryKey: materialsKeys.suppressions (locale),
queryFn: () => fetchMaterialSyncSuppressions (locale)})
const createMutation = useMutation ({
mutationFn: async () =>
@@ -71,17 +59,17 @@ const MaterialSyncSuppressionsPage: FC = () => {
sourceUri,
drivePath,
driveFileId,
reason}),
reason}, locale),
onSuccess: async () => {
await qc.invalidateQueries ({ queryKey: materialsKeys.suppressions () })
await qc.invalidateQueries ({ queryKey: materialsKeys.suppressions (locale) })
await qc.invalidateQueries ({ queryKey: materialsKeys.root })
setSourceUri ('')
setDrivePath ('')
setDriveFileId ('')
toast ({ title: '同期元抑止を登録しました' })
toast ({ title: label ('suppressions.registered') })
},
onError: () => {
toast ({ title: '同期元抑止の登録に失敗しました' })
toast ({ title: label ('suppressions.registerFailed') })
}})
const handleSubmit = (event: FormEvent) => {
@@ -93,12 +81,10 @@ const MaterialSyncSuppressionsPage: FC = () => {
return (
<MainArea>
<Helmet>
<title>{`素材同期抑止 | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('suppressions.title')}/>
<div className="space-y-5">
<PageTitle>同期抑止</PageTitle>
<PageTitle>{label ('suppressions.heading')}</PageTitle>
<form
onSubmit={handleSubmit}
@@ -106,21 +92,21 @@ const MaterialSyncSuppressionsPage: FC = () => {
text-stone-900 dark:border-stone-700 dark:bg-stone-900
dark:text-stone-100">
<div className="space-y-3">
<FormField label="同期元種別">
<FormField label={label ('suppressions.sourceKind')}>
{({ invalid }) => (
<select
value={sourceKind}
onChange={e => setSourceKind (
e.target.value as MaterialSyncSuppressionSourceKind)}
className={inputClass (invalid)}>
{Object.entries (SOURCE_KIND_LABELS).map (([value, label]) => (
<option key={value} value={value}>
{label}
{Object.entries (SOURCE_KIND_LABELS).map (([value, key]) => (
<option key={value} value={value} lang={message (key).language}>
{message (key).text}
</option>))}
</select>)}
</FormField>
<FormField label="同期元 URI">
<FormField label={label ('suppressions.sourceUri')}>
{({ invalid }) => (
<input
type="text"
@@ -129,7 +115,7 @@ const MaterialSyncSuppressionsPage: FC = () => {
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="Google Drive パス">
<FormField label={label ('suppressions.drivePath')}>
{({ invalid }) => (
<input
type="text"
@@ -138,7 +124,7 @@ const MaterialSyncSuppressionsPage: FC = () => {
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="Google Drive ファイル Id.">
<FormField label={label ('suppressions.driveFileId')}>
{({ invalid }) => (
<input
type="text"
@@ -147,15 +133,16 @@ const MaterialSyncSuppressionsPage: FC = () => {
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="事由">
<FormField label={label ('suppressions.reason')}>
{({ invalid }) => (
<select
value={reason}
onChange={e => setReason (e.target.value as MaterialSyncSuppressionReason)}
className={inputClass (invalid)}>
{REASONS.map (value => (
<option key={value} value={value}>
{REASON_NAMES[value]}
<option key={value} value={value}
lang={message (`suppressions.reasons.${value}`).language}>
{message (`suppressions.reasons.${value}`).text}
</option>))}
</select>)}
</FormField>
@@ -166,14 +153,14 @@ const MaterialSyncSuppressionsPage: FC = () => {
className="mt-4 rounded bg-blue-600 px-4 py-2 text-white
disabled:bg-gray-400"
disabled={createMutation.isPending}>
登録
{label ('suppressions.register')}
</Button>
</form>
{isLoading && <p>Loading...</p>}
{isLoading && <p>{label ('common:loading')}</p>}
{isError && (
<p className="text-red-600 dark:text-red-300">
同期元抑止一覧の取得に失敗しました.
{label ('suppressions.error')}
</p>)}
<div className="space-y-3">
@@ -184,14 +171,20 @@ const MaterialSyncSuppressionsPage: FC = () => {
text-stone-900 shadow-sm dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100">
<div className="font-medium">
{SOURCE_KIND_LABELS[suppression.sourceKind]}
{label (SOURCE_KIND_LABELS[suppression.sourceKind])}
</div>
<div className="break-all text-sm text-stone-700 dark:text-stone-300">
{suppression.normalizedSourceKey}
</div>
<div className="mt-2 text-sm text-stone-600 dark:text-stone-400">
事由: {reasonName (suppression.reason)} /
登録: {dateString (suppression.createdAt)}
{label ('suppressions.reasonPrefix')}
{REASONS.includes (suppression.reason as MaterialSyncSuppressionReason)
? label (`suppressions.reasons.${suppression.reason}`)
: suppression.reason} /{' '}
{label ('suppressions.registeredPrefix')}
<time dateTime={suppression.createdAt} lang={locale}>
{localisedDateString (suppression.createdAt, locale)}
</time>
</div>
</article>))}
</div>
+1 -1
ファイルの表示
@@ -63,7 +63,7 @@ describe ('PostDetailPage', () => {
renderPage ()
await waitFor (() => {
expect (postsApi.fetchPost).toHaveBeenCalledWith ('9')
expect (postsApi.fetchPost).toHaveBeenCalledWith ('9', 'ja')
})
expect (await screen.findByText ('Embed:https://example.com/9')).toBeInTheDocument ()
expect (screen.getByRole ('button', { name: '未閲覧' })).toBeInTheDocument ()
+23 -30
ファイルの表示
@@ -1,8 +1,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useEffect, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useEffect, useRef } from 'react'
import { useParams } from 'react-router-dom'
import { useLocale } from '@/lib/useLocale'
import PostEditForm from '@/components/PostEditForm'
import PostEmbed from '@/components/PostEmbed'
import PostList from '@/components/PostList'
@@ -11,13 +11,14 @@ import TabGroup, { Tab } from '@/components/common/TabGroup'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { isApiError } from '@/lib/api'
import { fetchPost, toggleViewedFlg } from '@/lib/posts'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { canEditContent } from '@/lib/users'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import NotFound from '@/pages/NotFound'
import ServiceUnavailable from '@/pages/ServiceUnavailable'
@@ -29,6 +30,8 @@ type Props = { user: User | null }
const PostDetailPage: FC<Props> = ({ user }) => {
const locale = useLocale ()
const { message, label } = useUITranslation ('posts')
const editable = canEditContent (user)
const behaviourSettings = useClientBehaviourSettings ()
const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal'
@@ -42,18 +45,18 @@ const PostDetailPage: FC<Props> = ({ user }) => {
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
const postId = String (id ?? '')
const postKey = postsKeys.show (postId)
const postKey = postsKeys.show (postId, locale)
const { data: post, isError: errorFlg, error } = useQuery ({
enabled: Boolean (id),
queryKey: postKey,
queryFn: () => fetchPost (postId) })
queryFn: () => fetchPost (postId, locale) })
const qc = useQueryClient ()
const embedRef = useRef<NiconicoViewerHandle> (null)
const [status, setStatus] = useState (200)
const status = (errorFlg && isApiError (error)) ? error.response?.status ?? 200 : 200
const changeViewedFlg = useMutation ({
mutationFn: async (next: boolean) => {
@@ -70,24 +73,15 @@ const PostDetailPage: FC<Props> = ({ user }) => {
onError: (...[, , ctx]) => {
if (ctx?.prev)
qc.setQueryData (postKey, ctx.prev)
toast ({ title: '失敗……', description: '通信に失敗しました……' })
toast ({ title: label ('shared:messages.failed'),
description: label ('detail.networkFailed') })
},
onSuccess: () => {
qc.invalidateQueries ({ queryKey: postsKeys.root })} })
useEffect (() => {
if (!(errorFlg))
return
const code = isApiError (error) ? error.response?.status : undefined
if (code)
setStatus (code)
}, [errorFlg, error])
useEffect (() => {
scroll (0, 0)
setStatus (200)
}, [id])
switch (status)
@@ -106,11 +100,10 @@ const PostDetailPage: FC<Props> = ({ user }) => {
<div
data-sidebar-container
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
<Helmet>
<PageHead title={post?.title || post?.url || message ('navigation:posts')}>
{(post?.thumbnail || post?.thumbnailBase) && (
<meta name="thumbnail" content={post.thumbnail! || post.thumbnailBase!}/>)}
{post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>}
</Helmet>
</PageHead>
{post && <TagDetailSidebar post={post} className="hidden md:block"/>}
@@ -120,7 +113,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
<>
{(post.childPosts ?? []).length > 0 && (
<div className="mb-4 bg-green-200 dark:bg-green-800 text-sm p-2 rounded-md">
<p>この投稿には {post.childPosts!.length} 件の子投稿があります.</p>
<p>{label ('detail.children', { count: post.childPosts!.length })}</p>
<PostList posts={[{ ...post, childPosts: [{ } as Post] },
...post.childPosts!.map (p => ({
...p, parentPosts: [{ } as Post] }))]}/>
@@ -135,9 +128,9 @@ const PostDetailPage: FC<Props> = ({ user }) => {
key={pp.id}
className="mb-4 bg-yellow-200 dark:bg-yellow-800 text-sm p-2 rounded-md">
<p>
この投稿には 1 件の親投稿{
siblings.length > 1
&& `と ${ siblings.length - 1 } 件の姉妹投稿`}があります.
{siblings.length > 1
? label ('detail.parentAndSiblings', { count: siblings.length - 1 })
: label ('detail.parent')}
</p>
<PostList posts={[{ ...pp, childPosts: [{ } as Post] },
...siblings.map (p => ({
@@ -168,20 +161,20 @@ const PostDetailPage: FC<Props> = ({ user }) => {
<Button onClick={() => changeViewedFlg.mutate (!(post.viewed))}
disabled={changeViewedFlg.isPending}
className={cn ('text-white', viewedClass)}>
{post.viewed ? '閲覧済' : '未閲覧'}
{label (post.viewed ? 'detail.viewed' : 'detail.unviewed')}
</Button>
<TabGroup>
<Tab name="関聯">
<Tab name={label ('shared:labels.relations')}>
{post.related.length > 0
? <PostList posts={post.related}/>
: 'まだないよ(笑)'}
: label ('detail.emptyRelated')}
</Tab>
{editable && (
<Tab name="編輯">
<Tab name={label ('shared:actions.edit')}>
<PostEditForm
post={post}
onSave={newPost => {
qc.setQueryData (postsKeys.show (postId),
qc.setQueryData (postsKeys.show (postId, locale),
(prev: Post | undefined) => newPost ?? prev)
qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root })
@@ -189,7 +182,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
</Tab>)}
</TabGroup>
</>)
: 'Loading...'}
: label ('common:loading')}
</MainArea>
{post && <TagDetailSidebar post={post} sp className="md:hidden"/>}
+2 -2
ファイルの表示
@@ -47,10 +47,10 @@ describe ('PostHistoryPage', () => {
externalTag: '7',
page: 1,
limit: 20,
})
}, 'ja')
})
expect (tagsApi.fetchExternalTag).toHaveBeenCalledWith ('7')
expect (tagsApi.fetchExternalTag).toHaveBeenCalledWith ('7', 'ja')
expect (tagsApi.fetchTag).not.toHaveBeenCalled ()
expect (
+60 -55
ファイルの表示
@@ -1,24 +1,25 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useEffect } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom'
import { useLocale } from '@/lib/useLocale'
import TagLink from '@/components/TagLink'
import PrefetchLink from '@/components/PrefetchLink'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { useDialogue } from '@/lib/dialogues/useDialogue'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { clientAnimationTransition,
clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchPostChanges, updatePost } from '@/lib/posts'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchExternalTag, fetchTag } from '@/lib/tags'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn, dateString, originalCreatedAtString } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import { cn, localisedDateString, originalCreatedAtString } from '@/lib/utils'
import type { FC, MouseEvent } from 'react'
@@ -39,6 +40,8 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => (
const PostHistoryPage: FC = () => {
const { message, label } = useUITranslation ('history')
const locale = useLocale ()
const dialogue = useDialogue ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
@@ -61,29 +64,30 @@ const PostHistoryPage: FC = () => {
const tagQueryId = tagId ?? ''
const { data: tag } = useQuery ({
enabled: Boolean (tagId),
queryKey: tagsKeys.show (tagQueryId),
queryFn: () => fetchTag (tagQueryId) })
queryKey: tagsKeys.show (tagQueryId, locale),
queryFn: () => fetchTag (tagQueryId, locale) })
const externalTagQueryId = externalTagId ?? ''
const { data: externalTag } = useQuery ({
enabled: Boolean (externalTagId),
queryKey: tagsKeys.externalShow (externalTagQueryId),
queryFn: () => fetchExternalTag (externalTagQueryId) })
queryKey: tagsKeys.externalShow (externalTagQueryId, locale),
queryFn: () => fetchExternalTag (externalTagQueryId, locale) })
const { data, isLoading: loading } = useQuery ({
const { data, isLoading: loading, isError } = useQuery ({
queryKey: postsKeys.changes ({ ...(id && { post: id }),
...(tagId && { tag: tagId }),
...(externalTagId && { externalTag: externalTagId }),
page, limit }),
page, limit }, locale),
queryFn: () => fetchPostChanges ({ ...(id && { post: id }),
...(tagId && { tag: tagId }),
...(externalTagId && {
externalTag: externalTagId }),
page, limit }) })
page, limit }, locale) })
const changes = data?.versions ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
const displayedTag = externalTag ?? tag
const pageTitleLabel = `耕作履歴${ id ? `: 投稿 #${ id }` : '' }${
const pageTitleLabel = `${message ('postTitle').text}${
id ? `${message ('postPrefix').text}#${id}` : '' }${
displayedTag ? `(${ displayedTag.name })` : '' }`
const qc = useQueryClient ()
@@ -92,27 +96,24 @@ const PostHistoryPage: FC = () => {
e.preventDefault ()
if (!(await dialogue.confirm ({
title: '差戻の確認',
description: `『${ change.title.current || change.url.current }』を版 ${
change.versionNo } に差戻します.\nよろしいですか?`,
confirmText: '差戻' })))
title: label ('confirmTitle'),
description: label ('confirmPost', {
name: change.title.current || change.url.current, version: change.versionNo }),
confirmText: label ('revert'),
cancelText: label ('settings:actions.cancel'),
closeLabel: label ('settings:actions.close') })))
return
try
{
const id = change.postId
const title = change.title.current
const tags =
change.tags
.filter (t => t.type !== 'removed')
.map (t => t.name)
.filter (t => t.slice (0, 5) !== 'nico:')
.join (' ')
const parentPostIds =
(change.parentPosts ?? [])
.filter (p => p.type !== 'removed')
.map (p => p.id)
.join (' ')
const currentTags = change.tags.filter (tag => tag.type !== 'removed' && !(tag.external))
if (currentTags.some (tag => tag.restoreName == null)
|| typeof change.parentPostIds !== 'string')
throw new Error ()
const tags = currentTags.map (tag => tag.restoreName).join (' ')
const parentPostIds = change.parentPostIds
const duration =
change.videoMs.current == null
? null
@@ -121,16 +122,16 @@ const PostHistoryPage: FC = () => {
const originalCreatedBefore = change.originalCreatedBefore.current
await updatePost ({ id, title, tags, duration, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ force: true })
{ force: true }, locale)
qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root })
toast ({ description: '差戻しました.' })
toast ({ description: label ('restored') })
}
catch
{
toast ({ description: '差戻に失敗……' })
toast ({ description: label ('restoreFailed') })
}
}
@@ -143,13 +144,14 @@ const PostHistoryPage: FC = () => {
return (
<MainArea>
<Helmet>
<title>{`耕作履歴 | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('postTitle')}/>
<PageTitle aria-label={pageTitleLabel}>
耕作履歴
{id && <>: 投稿 {<PrefetchLink to={`/posts/${ id }`}>#{id}</PrefetchLink>}</>}
<PageTitle aria-label={pageTitleLabel} lang={message ('postTitle').language}>
{label ('postTitle')}
{id && (
<>{label ('postPrefix')}
<PrefetchLink to={`/posts/${ id }`}>#{id}</PrefetchLink>
</>)}
{displayedTag && (
<>(<TagLink
tag={displayedTag}
@@ -157,7 +159,8 @@ const PostHistoryPage: FC = () => {
withCount={false}/>)</>)}
</PageTitle>
{loading ? 'Loading...' : (
{isError && <p role="alert">{label ('common:fetchError')}</p>}
{loading ? label ('common:loading') : (
<>
<div className="overflow-x-auto">
<table className="w-full min-w-[1200px] table-fixed border-collapse">
@@ -184,15 +187,15 @@ const PostHistoryPage: FC = () => {
<thead className="border-b-2 border-black dark:border-white">
<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">URL</th>
<th className="p-2 text-left">タグ</th>
<th className="p-2 text-left">{label ('post')}</th>
<th className="p-2 text-left">{label ('version')}</th>
<th className="p-2 text-left">{label ('shared:labels.title')}</th>
<th className="p-2 text-left">{label ('shared:url')}</th>
<th className="p-2 text-left">{label ('navigation:tags')}</th>
{/* TODO: 親投稿の履歴 */}
{/* <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 ('originalPostedAt')}</th>
<th className="p-2 text-left">{label ('tags:updatedAt')}</th>
<th className="p-2"/>
</tr>
</thead>
@@ -284,32 +287,34 @@ const PostHistoryPage: FC = () => {
{pp.title}
</span>))))}
</td> */}
<td className="p-2">
<td className="p-2"
lang={message ('unknownDate').language}>
{change.versionNo === 1
? originalCreatedAtString (change.originalCreatedFrom.current,
change.originalCreatedBefore.current)
? originalCreatedAtString (
change.originalCreatedFrom.current,
change.originalCreatedBefore.current, locale)
: renderDiff ({
current: originalCreatedAtString (
change.originalCreatedFrom.current,
change.originalCreatedBefore.current),
change.originalCreatedFrom.current,
change.originalCreatedBefore.current, locale),
prev: originalCreatedAtString (
change.originalCreatedFrom.prev,
change.originalCreatedBefore.prev) })}
change.originalCreatedFrom.prev,
change.originalCreatedBefore.prev, locale) })}
</td>
<td className="p-2">
{change.createdByUser
? (
<PrefetchLink to={`/users/${ change.createdByUser.id }`}>
{change.createdByUser.name
|| `名もなきニジラー(#${ change.createdByUser.id })`}
|| label ('anonymous', { id: change.createdByUser.id })}
</PrefetchLink>)
: 'bot 操作'}
: label ('bot')}
<br/>
{dateString (change.createdAt)}
{localisedDateString (change.createdAt, locale)}
</td>
<td className="p-2">
<a href="#" onClick={async e => await handleRevert (e, change)}>
復元
{label ('shared:actions.restore')}
</a>
</td>
</tr>)
+9 -4
ファイルの表示
@@ -130,7 +130,9 @@ describe ('PostImportReviewPage', () => {
renderReviewPage (['https://example.com/post'])
fireEvent.click (await screen.findByRole ('button', { name: '追加' }))
const submit = await screen.findByRole ('button', { name: '追加' })
await waitFor (() => expect (submit).toBeEnabled ())
fireEvent.click (submit)
expect (await screen.findAllByText ('登録失敗')).toHaveLength (2)
expect (screen.getAllByText ('invalid title')).toHaveLength (2)
@@ -147,11 +149,14 @@ describe ('PostImportReviewPage', () => {
renderReviewPage (['https://example.com/post'])
fireEvent.click (await screen.findByRole ('button', { name: '追加' }))
const submit = await screen.findByRole ('button', { name: '追加' })
await waitFor (() => expect (submit).toBeEnabled ())
fireEvent.click (submit)
await waitFor (() => {
expect (screen.getByLabelText ('current-location')).toHaveTextContent ('/posts')
expect (screen.getByLabelText ('current-location').textContent).toBe ('/ja/posts')
})
expect (api.apiPost).toHaveBeenCalledWith ('/posts/bulk', expect.any (FormData))
expect (api.apiPost).toHaveBeenCalledWith (
'/posts/bulk', expect.any (FormData), { locale: 'ja' })
})
})
+66 -57
ファイルの表示
@@ -1,19 +1,22 @@
import { AnimatePresence, motion } from 'framer-motion'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { ChevronRight } from 'lucide-react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import PrefetchLink from '@/components/PrefetchLink'
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
import { renderPostImportMessage } from '@/components/posts/import/postImportRowStatus'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { apiGet, apiPost, isApiError } from '@/lib/api'
import { translateUIMessage } from '@/lib/i18n'
import { useUITranslation } from '@/lib/useUITranslation'
import useDialogue from '@/lib/dialogues/useDialogue'
import { parsePostNewReviewUrls } from '@/lib/postNewQueryState'
import { validateImportSource } from '@/lib/postImportSourceValidation'
@@ -99,8 +102,9 @@ type ExistingSkippedRowsProps = {
id: string
rows: PostImportRow[] }
const DUPLICATE_URL_MESSAGE = 'URL が重複しています.'
const BULK_PROCESSING_FAILED_MESSAGE = '登録中にエラーが発生しました.'
const DUPLICATE_URL_MESSAGE = translateUIMessage ('validation.duplicateUrl', 'imports', 'ja').text
const BULK_PROCESSING_FAILED_MESSAGE =
translateUIMessage ('validation.processingFailed', 'imports', 'ja').text
const emptyAttributes = () => ({
title: '',
@@ -595,10 +599,12 @@ const editableRowDirty = (row: PostImportRow): boolean =>
const PostImportReviewPage: FC<Props> = ({ user }) => {
const locale = useLocale ()
const { message, label } = useUITranslation ('imports')
const editable = canEditContent (user)
const dialogue = useDialogue ()
const location = useLocation ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const existingRowsTransition =
@@ -608,7 +614,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
const [rows, setRows] = useState<PostImportRow[] | null> (null)
const [rows, setRows] = useState<PostImportRow[] | null> (
() => buildInitialRows (parsePostNewReviewUrls (location.search)))
const [metadataLoading, setMetadataLoading] = useState (false)
const [submitting, setSubmitting] = useState (false)
const [busyRowIds, setBusyRowIds] = useState<Set<number>> (new Set ())
@@ -649,9 +656,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const finishImport = useCallback (() => {
allowNextNavigation ()
clearPostImportSourceDraft (message =>
toast ({ title: '入力内容を削除できませんでした', description: message }))
toast ({ title: label ('review.clearFailed'),
description: renderPostImportMessage (message, locale) }))
navigate ('/posts')
}, [allowNextNavigation, navigate])
}, [allowNextNavigation, navigate, label, locale])
useEffect (() => {
const urls = parsePostNewReviewUrls (location.search)
@@ -696,7 +704,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
try
{
const preview = await apiGet<PostMetadataResponse> ('/posts/metadata', {
const preview = await apiGet<PostMetadataResponse> ('/posts/metadata', { locale,
params: { url: currentRow.url },
signal: controller.signal })
if (!(active) || metadataSequenceRef.current !== sequence)
@@ -770,7 +778,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
active = false
controllers.forEach (controller => controller.abort ())
}
}, [commitRows, currentRowBySource, location.search, updateRows])
}, [commitRows, currentRowBySource, location.search, updateRows, locale])
useEffect (() => {
if (editingRow == null || resultRepairMode (rowsRef.current) !== 'failed')
@@ -871,12 +879,12 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
urlChanged
? mergePreviewRow (
nextRow,
await apiGet<PostMetadataResponse> ('/posts/metadata', {
await apiGet<PostMetadataResponse> ('/posts/metadata', { locale,
params: { url: nextRow.url } }))
: nextRow
const dryRun = await apiPost<PostMetadataResponse> (
'/posts?dry=1',
buildDryRunFormData (candidateRow))
buildDryRunFormData (candidateRow), { locale })
const mergedRow = mergeDryRunRow (candidateRow, dryRun)
const mergedRows = updateRows (currentRows =>
replaceImportRow (currentRows, mergedRow))
@@ -910,7 +918,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
return { saved: false, row: applyThumbnailWarning (errorRow) }
}
toast ({ title: '行の再検証に失敗しました' })
toast ({ title: label ('review.validateFailed') })
return { saved: false, row: null }
}
}
@@ -924,8 +932,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
saveDraft (row, { draft, resetRequested })
await dialogue.form ({
title: '投稿を編輯',
cancelText: '取消',
title: label ('review.editPost'),
cancelText: label ('shared:actions.cancel'),
size: 'large',
body: controls => (
<PostImportRowForm
@@ -974,11 +982,11 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const result = await apiPost<{ results: BulkApiRow[] }>(
'/posts/bulk',
buildBulkFormData ([target]))
buildBulkFormData ([target]), { locale })
if (result.results.length !== 1)
{
updateRows (currentRows => replaceImportRow (currentRows, originalRow))
toast ({ title: '登録結果が不完全でした' })
toast ({ title: label ('review.incomplete') })
return
}
@@ -993,7 +1001,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
catch
{
updateRows (currentRows => replaceImportRow (currentRows, originalRow))
toast ({ title: '再試行に失敗しました' })
toast ({ title: label ('review.retryFailed') })
}
finally
{
@@ -1018,10 +1026,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
{
const result = await apiPost<{ results: BulkApiRow[] }>(
'/posts/bulk',
buildBulkFormData (latestProcessingRows))
buildBulkFormData (latestProcessingRows), { locale })
if (result.results.length !== latestProcessingRows.length)
{
toast ({ title: '登録結果が不完全でした' })
toast ({ title: label ('review.incomplete') })
return
}
@@ -1035,7 +1043,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
}
catch
{
toast ({ title: '登録に失敗しました' })
toast ({ title: label ('review.submitFailed') })
}
finally
{
@@ -1051,13 +1059,11 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
return (
<>
<Helmet>
<title>{`追加内容確認 | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('review.title')}/>
<MainArea className="pb-40">
<div className="mx-auto max-w-6xl space-y-6">
<PageTitle>追加内容確認</PageTitle>
<PageTitle>{label ('review.title')}</PageTitle>
{existingRows.length > 0 && (
<div className="rounded-lg border p-4">
@@ -1076,7 +1082,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
? ''
: 'transition-transform')}/>
</span>
既存投稿による自動スキップ {counts.existingSkipped}件
{label ('review.existingSkipped', { count: counts.existingSkipped })}
</button>
<AnimatePresence initial={false}>
{showExistingRows && (
@@ -1154,36 +1160,39 @@ const PostImportFooter = (
pendingOrErrorCount: number
onBack: () => void
onSubmit: () => void },
) => (
<div
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
dark:border-neutral-700 dark:bg-neutral-950/95">
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span>作成対象 {creatableCount}件</span>
<span>手動スキップ {manualSkippedCount}件</span>
<span>既存投稿による自動スキップ {existingSkippedCount}件</span>
<span>未処理 {pendingOrErrorCount}件</span>
) => {
const { label } = useUITranslation ('imports')
return (
<div
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
dark:border-neutral-700 dark:bg-neutral-950/95">
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span>{label ('review.creatable', { count: creatableCount })}</span>
<span>{label ('review.manualSkipped', { count: manualSkippedCount })}</span>
<span>{label ('review.existingSkipped', { count: existingSkippedCount })}</span>
<span>{label ('review.pending', { count: pendingOrErrorCount })}</span>
</div>
<div className="flex flex-col gap-2 md:flex-row">
<Button
type="button"
variant="outline"
className="w-full md:w-auto"
onClick={onBack}
disabled={submitting}>
{label ('review.back')}
</Button>
<Button
type="button"
className="w-full md:w-auto"
onClick={onSubmit}
disabled={metadataLoading || submitting || busyRowsPresent || !(canSubmit)}>
{creatableCount > 1 ? label ('review.addAll') : label ('shared:actions.add')}
</Button>
</div>
</div>
<div className="flex flex-col gap-2 md:flex-row">
<Button
type="button"
variant="outline"
className="w-full md:w-auto"
onClick={onBack}
disabled={submitting}>
戻る
</Button>
<Button
type="button"
className="w-full md:w-auto"
onClick={onSubmit}
disabled={metadataLoading || submitting || busyRowsPresent || !(canSubmit)}>
{creatableCount > 1 ? '一括追加' : '追加'}
</Button>
</div>
</div>
</div>)
</div>)
}
export default PostImportReviewPage
+4 -3
ファイルの表示
@@ -36,7 +36,8 @@ describe ('PostImportSourcePage', () => {
target: { value: '\nftp://example.com/file\nhttps://example.com/valid' } })
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
expect (screen.getByText (/2 行目: HTTP または HTTPS/)).toBeInTheDocument ()
expect (screen.getByText ('ftp://example.com/file').closest ('li'))
.toHaveTextContent ('2 行目: HTTP または HTTPS')
expect (screen.getByText ('ftp://example.com/file')).toBeInTheDocument ()
expect (input).toHaveAttribute ('aria-invalid', 'true')
expect (router.navigate).not.toHaveBeenCalled ()
@@ -51,8 +52,8 @@ describe ('PostImportSourcePage', () => {
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
expect (router.navigate).toHaveBeenCalledWith (
'/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba'
+ '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc')
'/ja/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba'
+ '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc', undefined)
})
it ('disables Next when the encoded request target reaches 4096 bytes', () => {
+27 -20
ファイルの表示
@@ -1,7 +1,9 @@
import { useEffect, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useNavigate } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { renderPostImportMessage } from '@/components/posts/import/postImportRowStatus'
import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form'
import FormField from '@/components/common/FormField'
@@ -10,7 +12,7 @@ import TextArea from '@/components/common/TextArea'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import {
buildPostNewReviewPath,
isPostNewReviewPathWithinLimit,
@@ -39,8 +41,10 @@ const SOURCE_ISSUES_ID = 'post-import-source-issues'
const PostImportSourcePage: FC<Props> = ({ user }) => {
const locale = useLocale ()
const { message: uiMessage, label } = useUITranslation ('imports')
const editable = canEditContent (user)
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const [source, setSource] = useState ('')
const [loading, setLoading] = useState (false)
const [sourceIssues, setSourceIssues] = useState<ReturnType<typeof validateImportSource>> ([])
@@ -51,7 +55,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
const lineCount = countImportSourceLines (source)
const sourceUrls = extractImportSourceUrls (source)
const withinPathLimit = isPostNewReviewPathWithinLimit (sourceUrls)
const messages = sourceError != null ? [sourceError] : []
const messages = sourceError != null ? [label ('source.urlRequired')] : []
const sourceDescribedBy =
[sourceError != null ? SOURCE_ERROR_ID : null,
sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null]
@@ -60,7 +64,8 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
useEffect (() => {
const draft = loadPostImportSourceDraft (message =>
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
toast ({ title: label ('source.restoreFailed'),
description: renderPostImportMessage (message, locale) }))
if (!(editedRef.current))
{
@@ -69,7 +74,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
? draft.source
: current)
}
}, [])
}, [label, locale])
useEffect (() => {
if (saveTimer.current != null)
@@ -77,20 +82,21 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
saveTimer.current = window.setTimeout (() => {
savePostImportSourceDraft (source, message =>
toast ({ title: '入力内容を保存できませんでした', description: message }))
toast ({ title: label ('source.saveFailed'),
description: renderPostImportMessage (message, locale) }))
}, 300)
return () => {
if (saveTimer.current != null)
window.clearTimeout (saveTimer.current)
}
}, [source])
}, [source, label, locale])
const preview = async () => {
if (lineCount === 0)
{
setSourceIssues ([])
setSourceError ('URL を入力してください.')
setSourceError (uiMessage ('source.urlRequired').text)
return
}
@@ -122,13 +128,11 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
return (
<MainArea>
<Helmet>
<title>{`広場に投稿を追加 | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={uiMessage ('source.title')}/>
<Form className="max-w-4xl">
<PageTitle>広場に投稿を追加</PageTitle>
<FormField label="URL リスト(1 行 1 URL)">
<PageTitle>{label ('source.title')}</PageTitle>
<FormField label={label ('source.urls')}>
{() => (
<TextArea
value={source}
@@ -139,8 +143,8 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
onBlur={() => {
savePostImportSourceDraft (source, message =>
toast ({
title: '入力内容を保存できませんでした',
description: message }))
title: label ('source.saveFailed'),
description: renderPostImportMessage (message, locale) }))
}}
onChange={ev => {
editedRef.current = true
@@ -157,21 +161,24 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
className="space-y-2 text-sm text-red-700 dark:text-red-300">
{sourceIssues.map (issue => (
<li key={`${ issue.sourceRow }-${ issue.message }-${ issue.url }`}>
<div>{issue.sourceRow} 行目: {issue.message}</div>
<div>
{label ('source.rowPrefix', { row: issue.sourceRow })}
{renderPostImportMessage (issue.message, locale)}
</div>
<div className="break-all font-mono text-xs">{issue.url}</div>
</li>))}
</ul>
<div className="relative z-10 flex items-center justify-between gap-3">
<div className="text-sm text-neutral-600 dark:text-neutral-300">
入力行数 {lineCount} / {MAX_ROWS}
{label ('source.lineCount', { count: lineCount, limit: MAX_ROWS })}
</div>
<Button
type="button"
className="pointer-events-auto shrink-0"
onClick={preview}
disabled={loading || !(withinPathLimit)}>
次へ
{label ('source.next')}
</Button>
</div>
</Form>
+5 -5
ファイルの表示
@@ -43,12 +43,12 @@ describe ('PostListPage', () => {
page: 2,
limit: 20,
order: 'original_created_at:desc',
}),
}), 'ja',
)
})
expect (await screen.findByRole ('link', { name: '投稿5' })).toHaveAttribute (
'href',
'/posts/5',
'/ja/posts/5',
)
expect (screen.getByText ('広場')).toBeInTheDocument ()
})
@@ -75,7 +75,7 @@ describe ('PostListPage', () => {
expect (await screen.findByText ('Wiki body')).toBeInTheDocument ()
expect (screen.getByRole ('link', { name: 'Wiki を見る' })).toHaveAttribute (
'href',
'/wiki/%E8%99%B9%E5%A4%8F',
'/ja/wiki/%E8%99%B9%E5%A4%8F',
)
})
@@ -92,7 +92,7 @@ describe ('PostListPage', () => {
expect.objectContaining ({
limit: 50,
order: 'original_created_at:desc',
}),
}), 'ja',
)
})
})
@@ -106,7 +106,7 @@ describe ('PostListPage', () => {
await waitFor (() => {
expect (postsApi.fetchPosts).toHaveBeenLastCalledWith (
expect.objectContaining ({ page: 1, limit: 100 }),
expect.objectContaining ({ page: 1, limit: 100 }), 'ja',
)
})
})
+33 -24
ファイルの表示
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import PostList from '@/components/PostList'
import PrefetchLink from '@/components/PrefetchLink'
import TagSidebar from '@/components/TagSidebar'
@@ -10,8 +10,9 @@ import WikiBody from '@/components/WikiBody'
import Pagination from '@/components/common/Pagination'
import TabGroup, { Tab } from '@/components/common/TabGroup'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { fetchPosts } from '@/lib/posts'
import { localeLanguage } from '@/lib/locales'
import { postsKeys } from '@/lib/queryKeys'
import {
DEFAULT_POST_LIST_LIMIT,
@@ -21,6 +22,8 @@ import {
} from '@/lib/settings'
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { fetchWikiPageByTitle } from '@/lib/wiki'
import { useUITranslation } from '@/lib/useUITranslation'
import { useHydrated } from '@/lib/useHydrated'
import type { FC } from 'react'
@@ -33,12 +36,15 @@ const parseLimit = (value: string | null): 20 | 50 | 100 | null => {
const PostListPage: FC = () => {
const locale = useLocale ()
const hydrated = useHydrated ()
const { message, label, siteTitle } = useUITranslation ('posts')
const containerRef = useRef<HTMLDivElement | null> (null)
const [wikiPage, setWikiPage] = useState<WikiPage | null> (null)
const location = useLocation ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const query = new URLSearchParams (location.search)
const tagsQuery = query.get ('tags') ?? ''
const anyFlg = query.get ('match') === 'any'
@@ -48,26 +54,29 @@ const PostListPage: FC = () => {
const page = Number (query.get ('page') ?? 1)
const limit = (
parseLimit (query.get ('limit'))
?? getClientListLimit ('postList')
?? (hydrated ? getClientListLimit ('postList') : null)
?? DEFAULT_POST_LIST_LIMIT
)
const order = 'original_created_at:desc' as const
const keys = {
tags: tagsKey, match, page, limit,
...(query.get ('tag_refs') && { tagReferences: query.get ('tag_refs')! }),
url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
order } as const
const { data, isLoading: loading } = useQuery ({
queryKey: postsKeys.index (keys),
queryFn: () => fetchPosts (keys) })
queryKey: postsKeys.index (keys, locale),
queryFn: () => fetchPosts (keys, locale) })
const posts = data?.posts ?? []
const cursor = ''
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
if (!hydrated)
return
setClientListSettings ('postList', { limit })
}, [limit])
}, [hydrated, limit])
const updatePage = (nextPage: number) => {
const params = new URLSearchParams (location.search)
@@ -111,27 +120,25 @@ const PostListPage: FC = () => {
try
{
const tagName = tags[0]
setWikiPage (await fetchWikiPageByTitle (tagName, { }))
setWikiPage (await fetchWikiPageByTitle (tagName, { }, locale))
}
catch
{
;
}
}) ()
}, [location.search, tags])
}, [location.search, tags, locale])
return (
<div
data-sidebar-container
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"
ref={containerRef}>
<Helmet>
<title>
{tags.length
? `${ tags.join (anyFlg ? ' or ' : ' and ') } | ${ SITE_TITLE }`
: `${ SITE_TITLE }\u3000〜 ぼざろクリーチャーシリーズ綜合リンク集サイト`}
</title>
</Helmet>
<PageHead
title={tags.length
? tags.join (message (anyFlg ? 'list.or' : 'list.and').text)
: message ('list.tagline', { brand: siteTitle.text })}
includeSiteName={tags.length > 0}/>
<TagSidebar posts={posts.slice (0, 20)} onClick={() => {
const statesToSave = {
@@ -145,7 +152,7 @@ const PostListPage: FC = () => {
<TabGroup
rightAddon={
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>件数</span>
<span>{label ('common:countLabel')}</span>
<select
className="rounded border border-border bg-background px-2 py-1 text-sm
text-foreground"
@@ -160,22 +167,24 @@ const PostListPage: FC = () => {
</select>
</label>
}>
<Tab name="広場">
<Tab name={label ('navigation:posts')}>
{posts.length > 0
? (
<>
<PostList posts={posts}/>
<Pagination page={page} totalPages={totalPages}/>
</>)
: !(loading) && '広場には何もありませんよ.'}
{loading && 'Loading...'}
: !(loading) && label ('list.empty')}
{loading && label ('common:loading')}
</Tab>
{tags.length === 1 && (
<Tab name="Wiki">
<WikiBody title={tags[0]} body={wikiPage?.body}/>
<Tab name={label ('navigation:wiki')}>
<WikiBody title={tags[0]} body={wikiPage?.body}
language={wikiPage?.languageCode ?? (wikiPage?.resourceType === 'tag_description'
? localeLanguage (locale) : 'und')}/>
<div className="my-2">
<PrefetchLink to={`/wiki/${ encodeURIComponent (tags[0]) }`}>
Wiki を見る
{label ('list.viewWiki')}
</PrefetchLink>
</div>
</Tab>)}
+5 -5
ファイルの表示
@@ -43,12 +43,12 @@ describe ('PostSearchPage', () => {
match: 'any',
page: 2,
limit: 20,
}),
}), 'ja',
)
})
expect ((await screen.findAllByRole ('link', { name: '検索対象' }))[0]).toHaveAttribute (
'href',
'/posts/4',
'/ja/posts/4',
)
})
@@ -72,7 +72,7 @@ describe ('PostSearchPage', () => {
tags: 'tag',
match: 'any',
page: 1,
}),
}), 'ja',
)
})
})
@@ -98,7 +98,7 @@ describe ('PostSearchPage', () => {
await waitFor (() => {
expect (postsApi.fetchPosts).toHaveBeenCalledWith (
expect.objectContaining ({ limit: 50, order: 'created_at:desc' }),
expect.objectContaining ({ limit: 50, order: 'created_at:desc' }), 'ja',
)
})
expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}'))
@@ -117,7 +117,7 @@ describe ('PostSearchPage', () => {
await waitFor (() => {
expect (postsApi.fetchPosts).toHaveBeenLastCalledWith (
expect.objectContaining ({ page: 1, limit: 100 }),
expect.objectContaining ({ page: 1, limit: 100 }), 'ja',
)
})
})
+55 -38
ファイルの表示
@@ -1,10 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import PrefetchLink from '@/components/PrefetchLink'
import LocalisedText from '@/components/LocalisedText'
import SortHeader from '@/components/SortHeader'
import TagLink from '@/components/TagLink'
import DateTimeField from '@/components/common/DateTimeField'
@@ -13,7 +14,7 @@ import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { clientAnimationTransition,
clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchPosts } from '@/lib/posts'
@@ -28,7 +29,9 @@ import {
} from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils'
import { localisedDateString, inputClass, originalCreatedAtString } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import { useHydrated } from '@/lib/useHydrated'
import type { FC, FormEvent } from 'react'
@@ -70,6 +73,9 @@ const parseOrder = (value: string | null): FetchPostsOrder | null => {
const PostSearchPage: FC = () => {
const locale = useLocale ()
const hydrated = useHydrated ()
const { message, label } = useUITranslation ('posts')
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const thumbnailTransition = clientAnimationTransition (
@@ -79,7 +85,7 @@ const PostSearchPage: FC = () => {
)
const location = useLocation ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const query = useMemo (() => new URLSearchParams (location.search),
[location.search])
@@ -87,7 +93,7 @@ const PostSearchPage: FC = () => {
const page = Number (query.get ('page') ?? 1)
const limit = (
parseLimit (query.get ('limit'))
?? getClientListLimit ('postSearch')
?? (hydrated ? getClientListLimit ('postSearch') : null)
?? DEFAULT_POST_LIST_LIMIT
)
@@ -103,7 +109,7 @@ const PostSearchPage: FC = () => {
const qUpdatedTo = query.get ('updated_to') ?? ''
const order = (
parseOrder (query.get ('order'))
?? getClientListOrder<FetchPostsOrder> ('postSearch')
?? (hydrated ? getClientListOrder<FetchPostsOrder> ('postSearch') : null)
?? DEFAULT_POST_LIST_ORDER
)
@@ -120,6 +126,7 @@ const PostSearchPage: FC = () => {
const keys: FetchPostsParams = {
tags: qTags, match: qMatch, page, limit,
...(query.get ('tag_refs') && { tagReferences: query.get ('tag_refs')! }),
url: qURL,
title: qTitle,
originalCreatedFrom: qOriginalCreatedFrom,
@@ -130,14 +137,16 @@ const PostSearchPage: FC = () => {
updatedTo: qUpdatedTo,
order }
const { data, isLoading: loading } = useQuery ({
queryKey: postsKeys.index (keys),
queryFn: () => fetchPosts (keys) })
queryKey: postsKeys.index (keys, locale),
queryFn: () => fetchPosts (keys, locale) })
const results = data?.posts ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
if (!hydrated)
return
setClientListSettings ('postSearch', { limit, order })
}, [limit, order])
}, [hydrated, limit, order])
useEffect (() => {
setURL (qURL ?? '')
@@ -220,15 +229,13 @@ const PostSearchPage: FC = () => {
return (
<MainArea>
<Helmet>
<title>広場検索 | {SITE_TITLE}</title>
</Helmet>
<PageHead title={message ('search.title')}/>
<div className="max-w-xl">
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle>広場検索</PageTitle>
<PageTitle>{label ('search.title')}</PageTitle>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>件数</span>
<span>{label ('common:countLabel')}</span>
<select
className="rounded border border-border bg-background px-2 py-1 text-sm
text-foreground"
@@ -246,7 +253,7 @@ const PostSearchPage: FC = () => {
<form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */}
<FormField label="タイトル">
<FormField label={label ('shared:labels.title')}>
{({ invalid }) => (
<input
type="text"
@@ -257,7 +264,7 @@ const PostSearchPage: FC = () => {
</FormField>
{/* URL */}
<FormField label="URL">
<FormField label={label ('shared:url')}>
{({ invalid }) => (
<input
type="text"
@@ -267,7 +274,7 @@ const PostSearchPage: FC = () => {
</FormField>
{/* タグ */}
<FormField label="タグ">
<FormField label={label ('navigation:tags')}>
{() => (
<TagInput
includeNico={true}
@@ -276,14 +283,14 @@ const PostSearchPage: FC = () => {
</FormField>
<div>
<fieldset className="w-full my-2">
<label>検索区分:</label>
<label>{label ('search.matchType')}</label>
<label className="mx-2">
<input
type="radio"
name="match-type"
checked={matchType === 'all'}
onChange={() => setMatchType ('all')}/>
AND
{label ('search.all')}
</label>
<label className="mx-2">
<input
@@ -291,13 +298,13 @@ const PostSearchPage: FC = () => {
name="match-type"
checked={matchType === 'any'}
onChange={() => setMatchType ('any')}/>
OR
{label ('search.any')}
</label>
</fieldset>
</div>
{/* オリジナルの投稿日時 */}
<FormField label="オリジナルの投稿日時">
<FormField label={label ('history:originalPostedAt')}>
{() => (
<>
<DateTimeField
@@ -311,7 +318,7 @@ const PostSearchPage: FC = () => {
</FormField>
{/* 投稿日時 */}
<FormField label="投稿日時">
<FormField label={label ('search.createdAt')}>
{() => (
<>
<DateTimeField
@@ -325,7 +332,7 @@ const PostSearchPage: FC = () => {
</FormField>
{/* 更新日時 */}
<FormField label="更新日時">
<FormField label={label ('tags:updatedAt')}>
{() => (
<>
<DateTimeField
@@ -343,13 +350,13 @@ const PostSearchPage: FC = () => {
<button
type="submit"
className="bg-blue-500 text-white px-4 py-2 rounded">
検索
{label ('common:search')}
</button>
</div>
</form>
</div>
{loading ? 'Loading...' : (results.length > 0 ? (
{loading ? label ('common:loading') : (results.length > 0 ? (
<div className="mt-4">
<div className="overflow-x-auto">
<table className="w-full min-w-[1200px] table-fixed border-collapse">
@@ -365,40 +372,40 @@ const PostSearchPage: FC = () => {
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th className="p-2 text-left whitespace-nowrap">投稿</th>
<th className="p-2 text-left whitespace-nowrap">{label ('history:post')}</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchPostsOrderField>
by="title"
label="タイトル"
label={label ('shared:labels.title')}
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchPostsOrderField>
by="url"
label="URL"
label={label ('shared:url')}
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">タグ</th>
<th className="p-2 text-left whitespace-nowrap">{label ('navigation:tags')}</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchPostsOrderField>
by="original_created_at"
label="オリジナルの投稿日時"
label={label ('history:originalPostedAt')}
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchPostsOrderField>
by="created_at"
label="投稿日時"
label={label ('search.createdAt')}
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchPostsOrderField>
by="updated_at"
label="更新日時"
label={label ('tags:updatedAt')}
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
@@ -443,18 +450,28 @@ const PostSearchPage: FC = () => {
</span>))}
</td>
<td className="p-2">
{originalCreatedAtString (row.originalCreatedFrom,
row.originalCreatedBefore)}
<LocalisedText language={locale}>
{originalCreatedAtString (row.originalCreatedFrom,
row.originalCreatedBefore, locale)}
</LocalisedText>
</td>
<td className="p-2">
<LocalisedText language={locale}>
{localisedDateString (row.createdAt, locale)}
</LocalisedText>
</td>
<td className="p-2">
<LocalisedText language={locale}>
{localisedDateString (row.updatedAt, locale)}
</LocalisedText>
</td>
<td className="p-2">{dateString (row.createdAt)}</td>
<td className="p-2">{dateString (row.updatedAt)}</td>
</tr>))}
</tbody>
</table>
</div>
<Pagination page={page} totalPages={totalPages}/>
</div>) : '結果ないよ(笑)')}
</div>) : label ('tags:empty'))}
</MainArea>)
}
+13 -12
ファイルの表示
@@ -5,6 +5,7 @@ import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import { dateString } from '@/lib/utils'
import { buildTag, buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import { textContentOf } from '@/test/text'
import type { NicoTag } from '@/types'
@@ -26,10 +27,10 @@ const scrollIntoView = vi.fn ()
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
vi.mock ('@/lib/dialogues/useDialogue', async importOriginal => ({
...await importOriginal<
typeof import('@/components/dialogues/DialogueProvider')> (),
useDialogue: () => dialogue }))
typeof import('@/lib/dialogues/useDialogue')> (),
default: () => dialogue }))
const buildNicoTag = (values: Partial<NicoTag> = {}): NicoTag => ({
...buildTag (),
@@ -74,7 +75,7 @@ describe ('NicoTagListPage', () => {
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/nico',
{ params: {
{ locale: 'ja', params: {
page: 2,
limit: 20,
name: 'linked',
@@ -106,7 +107,7 @@ describe ('NicoTagListPage', () => {
await waitFor (() => {
expect (api.apiGet).toHaveBeenLastCalledWith (
'/tags/nico',
{ params: {
{ locale: 'ja', params: {
page: 1,
limit: 20,
name: 'linked',
@@ -151,7 +152,7 @@ describe ('NicoTagListPage', () => {
await waitFor (() => {
expect (api.apiGet).toHaveBeenLastCalledWith (
'/tags/nico',
{ params: expect.objectContaining ({
{ locale: 'ja', params: expect.objectContaining ({
page: 1,
name: 'source',
linked_tag: 'destination',
@@ -191,7 +192,7 @@ describe ('NicoTagListPage', () => {
expect (api.apiPut).toHaveBeenCalledWith (
'/tags/nico/7',
expect.any (FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } },
{ locale: 'ja', headers: { 'Content-Type': 'multipart/form-data' } },
)
})
expect (await screen.findByText ('1 件')).toBeInTheDocument ()
@@ -217,11 +218,11 @@ describe ('NicoTagListPage', () => {
fireEvent.click (screen.getAllByRole ('button', { name: '編集' })[0])
await waitFor (() => {
expect (dialogue.confirm).toHaveBeenCalledWith ({
title: '編集中の内容を破棄しますか?',
confirmText: '破棄',
variant: 'danger',
})
expect (dialogue.confirm).toHaveBeenCalledTimes (1)
const options = dialogue.confirm.mock.calls[0][0]
expect (textContentOf (options.title)).toBe ('編集中の内容を破棄しますか?')
expect (textContentOf (options.confirmText)).toBe ('破棄')
expect (options.variant).toBe ('danger')
})
expect (screen.getAllByLabelText ('連携する広場タグ')).toHaveLength (1)
expect (screen.getByLabelText ('連携する広場タグ')).toHaveValue ('入力中')
+98 -68
ファイルの表示
@@ -1,9 +1,9 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Check, LoaderCircle, Pencil, X } from 'lucide-react'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import TagLink from '@/components/TagLink'
import SortHeader from '@/components/SortHeader'
import FieldError from '@/components/common/FieldError'
@@ -11,17 +11,20 @@ import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import TextArea from '@/components/common/TextArea'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import useDialogue from '@/lib/dialogues/useDialogue'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { apiPut } from '@/lib/api'
import { extractValidationError } from '@/lib/apiErrors'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { tagsKeys } from '@/lib/queryKeys'
import { fetchNicoTags } from '@/lib/tags'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn, dateString, inputClass } from '@/lib/utils'
import { cn, localisedDateString, inputClass } from '@/lib/utils'
import LocalisedText from '@/components/LocalisedText'
import { useUITranslation } from '@/lib/useUITranslation'
import { translateUIMessage } from '@/lib/i18n'
import { canEditContent } from '@/lib/users'
import type { FC, FormEvent } from 'react'
@@ -40,11 +43,14 @@ const setIf = (qs: URLSearchParams, key: string, value: string) => {
const NicoTagListPage: FC<Props> = ({ user }) => {
const locale = useLocale ()
const { message, label } = useUITranslation ('tags')
const fallbackUpdateError = translateUIMessage ('nico.updateFailed', 'tags', 'ja').text
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const dialogue = useDialogue ()
const location = useLocation ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const queryClient = useQueryClient ()
const query = useMemo (() => new URLSearchParams (location.search), [location.search])
@@ -66,8 +72,8 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
const keys = {
name: qName, linkedTag: qLinkedTag, linkStatus: qLinkStatus, page, limit, order }
const { data, isError, isLoading: loading } = useQuery ({
queryKey: tagsKeys.nicoIndex (keys),
queryFn: () => fetchNicoTags (keys) })
queryKey: tagsKeys.nicoIndex (keys, locale),
queryFn: () => fetchNicoTags (keys, locale) })
const nicoTags = data?.tags ?? []
const count = data?.count ?? 0
@@ -100,8 +106,8 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
if (editingId != null && editingId !== tag.id && editingChanged
&& !(await dialogue.confirm ({
title: '編集中の内容を破棄しますか?',
confirmText: '破棄',
title: label ('nico.discardConfirm'),
confirmText: label ('nico.discard'),
variant: 'danger'})))
return
@@ -128,11 +134,11 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
try
{
await apiPut<Tag[]> (`/tags/nico/${ id }`, formData,
{ headers: { 'Content-Type': 'multipart/form-data' } })
{ locale, headers: { 'Content-Type': 'multipart/form-data' } })
setErrorsByTagId (errors => ({ ...errors, [id]: [] }))
setEditingId (null)
await queryClient.invalidateQueries ({ queryKey: tagsKeys.nicoRoot })
toast ({ description: '連携を更新しました.' })
toast ({ description: label ('nico.updated') })
}
catch (e)
{
@@ -141,8 +147,9 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
...errors,
[id]: validationError?.fieldErrors.tags
?? validationError?.baseErrors
?? ['更新できませんでした.']}))
toast ({ title: '更新失敗', description: '入力内容を確認してください.' })
?? [fallbackUpdateError]}))
toast ({ title: label ('shared:messages.updateFailed'),
description: label ('shared:messages.checkInputContent') })
}
finally
{
@@ -171,52 +178,58 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
useEffect (() => {
if (isError)
toast ({ title: '読込失敗', description: 'ニコニコ連携を読み込めませんでした.' })
}, [isError])
{
toast ({ title: label ('nico.loadFailed'),
description: label ('nico.loadFailedDescription') })
}
}, [isError, label])
return (
<MainArea>
<Helmet>
<title>ニコニコ連携 | {SITE_TITLE}</title>
</Helmet>
<MainArea className="min-w-0">
<PageHead title={message ('navigation:nicoLinks')}/>
<div className="max-w-xl">
<PageTitle>ニコニコ連携</PageTitle>
<PageTitle>{label ('navigation:nicoLinks')}</PageTitle>
<p className="mb-4 text-sm text-gray-600 dark:text-gray-300">
ニコニコタグを広場のタグへ結び付けます.
{label ('nico.description')}
</p>
<form onSubmit={handleSearch} className="space-y-2">
<FormField label="ニコニコタグ">
<FormField label={label ('common:categories.nico')}>
{({ invalid }) => (
<input
type="text"
aria-label="ニコニコタグ"
aria-label={message ('common:categories.nico').text}
lang={message ('common:categories.nico').language}
value={name}
onChange={e => setName (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="連携タグ">
<FormField label={label ('nico.linkedTags')}>
{({ invalid }) => (
<input
type="text"
aria-label="連携タグ"
aria-label={message ('nico.linkedTags').text}
lang={message ('nico.linkedTags').language}
value={linkedTag}
onChange={e => setLinkedTag (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="連携状態">
<FormField label={label ('nico.linkStatus')}>
{({ invalid }) => (
<select
aria-label="連携状態"
aria-label={message ('nico.linkStatus').text}
lang={message ('nico.linkStatus').language}
value={linkStatus}
onChange={e => setLinkStatus (e.target.value as LinkStatus)}
className={inputClass (invalid)}>
<option value="all">すべて</option>
<option value="linked">連携済み</option>
<option value="unlinked">未連携</option>
{(['all', 'linked', 'unlinked'] as const).map (value => (
<option key={value} value={value}
lang={message (`nico.${value}`).language}>
{message (`nico.${value}`).text}
</option>))}
</select>)}
</FormField>
@@ -224,53 +237,56 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
<button
type="submit"
className="rounded bg-blue-500 px-4 py-2 text-white">
検索
{label ('common:search')}
</button>
</div>
</form>
</div>
{loading
? 'Loading...'
? label ('common:loading')
: (
<div className="mt-6">
<div className="mt-6 min-w-0 max-w-full">
<div className="mb-3 flex items-baseline justify-between gap-4">
<h2 className="text-lg font-bold">検索結果</h2>
<span className="text-sm text-gray-500 dark:text-gray-400">{count} 件</span>
<h2 className="text-lg font-bold">{label ('shared:results')}</h2>
<span className="text-sm text-gray-500 dark:text-gray-400">
{label ('shared:labels.resultsCount', { count })}
</span>
</div>
{nicoTags.length > 0
? (
<div className="overflow-x-auto">
<table className="w-full min-w-[800px] table-fixed border-collapse">
<div className="min-w-0 max-w-full overflow-x-auto">
<table className="w-full min-w-[800px] table-auto border-collapse">
<colgroup>
<col className="w-64"/>
<col className="w-[48rem]"/>
<col/>
<col className="w-56"/>
<col className="w-56"/>
{editable && <col className="w-24"/>}
</colgroup>
<thead className="border-b-2 border-black dark:border-white">
<thead className="border-b-2 border-black dark:border-white
[&_.ainu-ui-text]:!whitespace-nowrap [&_wbr]:hidden">
<tr>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchNicoTagsOrderField>
by="name"
label="ニコニコタグ"
label={label ('common:categories.nico')}
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left">連携タグ</th>
<th className="p-2 text-left">{label ('nico.linkedTags')}</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchNicoTagsOrderField>
by="created_at"
label="最初に記載された日時"
label={label ('nico.firstRecorded')}
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchNicoTagsOrderField>
by="updated_at"
label="最近記載された日時"
label={label ('nico.recentRecorded')}
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
@@ -280,6 +296,8 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
<tbody>
{nicoTags.map (tag => {
const isEditing = editingId === tag.id
const errors = errorsByTagId[tag.id]?.map (value =>
value === fallbackUpdateError ? label ('nico.updateFailed') : value)
return (
<Fragment key={tag.id}>
@@ -291,34 +309,45 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
? 'bg-rose-50 dark:bg-rose-950/30'
: 'even:bg-gray-100 dark:even:bg-gray-800')}>
<td className="p-2 align-top font-semibold">
<TagLink tag={tag} withWiki={false} withCount={false}/>
<div className="max-w-[24rem]">
<TagLink tag={tag} withWiki={false} withCount={false}/>
</div>
</td>
<td className="p-2 align-top">
{tag.linkedTags.map ((linkedTag, i) => (
<span key={linkedTag.id}>
{i > 0 && ' '}
<TagLink
tag={linkedTag}
linkFlg={false}
withCount={false}/>
</span>))}
<div className="flex max-w-[24rem] flex-wrap items-start gap-1">
{tag.linkedTags.map (linkedTag => (
<span key={linkedTag.id} className="min-w-0 max-w-full">
<TagLink
tag={linkedTag}
linkFlg={false}
withCount={false}/>
</span>))}
</div>
</td>
<td className="p-2 align-top whitespace-nowrap">
{dateString (tag.createdAt)}
<td className="w-px p-2 align-top whitespace-nowrap
[&_.ainu-ui-text]:!whitespace-nowrap [&_wbr]:hidden">
<LocalisedText language={locale}>
{localisedDateString (tag.createdAt, locale)}
</LocalisedText>
</td>
<td className="p-2 align-top whitespace-nowrap">
{tag.recentPostTagCreatedAt && dateString (tag.recentPostTagCreatedAt)}
<td className="w-px p-2 align-top whitespace-nowrap
[&_.ainu-ui-text]:!whitespace-nowrap [&_wbr]:hidden">
{tag.recentPostTagCreatedAt && (
<LocalisedText language={locale}>
{localisedDateString (tag.recentPostTagCreatedAt, locale)}
</LocalisedText>)}
</td>
{editable && (
<td className="p-2 text-right align-top">
<td className="w-px p-2 text-right align-top">
{!(isEditing) && (
<button
type="button"
onClick={() => beginEdit (tag)}
className="inline-flex items-center gap-1 text-sm text-blue-700
hover:underline dark:text-blue-300">
<Pencil className="size-3.5"/>
編集
className="inline-flex w-max items-center gap-1 whitespace-nowrap
text-sm text-blue-700 hover:underline dark:text-blue-300
[&_.ainu-ui-text]:!whitespace-nowrap [&_wbr]:hidden">
<Pencil className="size-3.5 shrink-0"/>
{label ('nico.edit')}
</button>)}
</td>)}
</tr>
@@ -330,18 +359,19 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
<div className="space-y-2">
<label htmlFor={`nico-links-${ tag.id }`}
className="block text-sm font-semibold">
連携する広場タグ
{label ('nico.tagsToLink')}
</label>
<TextArea
id={`nico-links-${ tag.id }`}
value={rawTags[tag.id] ?? ''}
invalid={(errorsByTagId[tag.id] ?? []).length > 0}
className="min-h-24 resize-y"
placeholder="タグ名を空白または改行で区切って入力"
placeholder={message ('nico.tagsPlaceholder').text}
lang={message ('nico.tagsPlaceholder').language}
onChange={e => setRawTags (rawTags => ({
...rawTags,
[tag.id]: e.target.value}))}/>
<FieldError messages={errorsByTagId[tag.id]}/>
<FieldError messages={errors}/>
<div className="flex justify-end gap-2">
<button
type="button"
@@ -351,7 +381,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
border-gray-300 px-3 py-1.5 text-sm
disabled:opacity-50 dark:border-gray-700">
<X className="size-3.5"/>
取消
{label ('shared:actions.cancel')}
</button>
<button
type="button"
@@ -362,7 +392,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
{savingId === tag.id
? <LoaderCircle className="size-3.5 animate-spin"/>
: <Check className="size-3.5"/>}
保存
{label ('settings:actions.save')}
</button>
</div>
</div>
@@ -373,7 +403,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
</tbody>
</table>
</div>)
: <p>結果ないよ(笑)</p>}
: <p>{label ('empty')}</p>}
<Pagination page={page} totalPages={totalPages}/>
</div>)}
+5 -3
ファイルの表示
@@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { textContentOf } from '@/test/text'
import TagDetailPage from '@/pages/tags/TagDetailPage'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
@@ -60,11 +61,12 @@ describe ('TagDetailPage', () => {
fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!)
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith ('/tags/7', expect.any (FormData))
expect (api.apiPut).toHaveBeenCalledWith ('/tags/7', expect.any (FormData), { locale: 'ja' })
})
const formData = api.apiPut.mock.calls[0]?.[1] as FormData
expect (formData.get ('name')).toBe ('new')
expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' })
expect (toastApi.toast).toHaveBeenCalledTimes (1)
expect (textContentOf (toastApi.toast.mock.calls[0][0].description)).toBe ('更新しました.')
})
it ('keeps nico tags disabled', async () => {
@@ -77,7 +79,7 @@ describe ('TagDetailPage', () => {
it ('shows validation errors returned for tag fields', async () => {
tagsApi.fetchTag.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' }))
api.isApiError.mockReturnValue (true)
api.isApiError.mockImplementation (error => error?.response != null)
api.apiPut.mockRejectedValueOnce ({
response: {
status: 422,
+37 -24
ファイルの表示
@@ -2,18 +2,22 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { useLocale } from '@/lib/useLocale'
import TagLink from '@/components/TagLink'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import PageHead from '@/components/common/PageHead'
import { toast } from '@/components/ui/use-toast'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiPut } from '@/lib/api'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { CATEGORIES } from '@/consts'
import { apiPut, isApiError } from '@/lib/api'
import NotFound from '@/pages/NotFound'
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTag } from '@/lib/tags'
import { cn, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, FormEvent } from 'react'
@@ -28,20 +32,23 @@ type TagFormField =
const TagDetailPage: FC = () => {
const locale = useLocale ()
const { message, label } = useUITranslation ('tags')
const { id } = useParams ()
const tagId = String (id ?? '')
const tagKey = tagsKeys.show (tagId)
const tagKey = tagsKeys.show (tagId, locale)
const { data: tag, isLoading: loading } = useQuery ({
const { data: tag, isLoading: loading, error } = useQuery ({
queryKey: tagKey,
queryFn: () => fetchTag (tagId) })
queryFn: () => fetchTag (tagId, locale) })
const [name, setName] = useState ('')
const [category, setCategory] = useState<Category> ('general')
const [aliases, setAliases] = useState ('')
const [parentTags, setParentTags] = useState ('')
const [deprecated, setDeprecated] = useState (false)
const [disabled, setDisabled] = useState (true)
const [name, setName] = useState (tag?.name ?? '')
const [category, setCategory] = useState<Category> (tag?.category ?? 'general')
const [aliases, setAliases] = useState (tag?.aliases.join (' ') ?? '')
const [parentTags, setParentTags] = useState (tag?.parents.map (entry => entry.name).join (' ')
?? '')
const [deprecated, setDeprecated] = useState (tag?.deprecatedAt != null)
const [disabled, setDisabled] = useState (tag == null || tag.category === 'nico')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<TagFormField> ()
@@ -60,7 +67,7 @@ const TagDetailPage: FC = () => {
try
{
const data = await apiPut<Tag> (`/tags/${ id }`, formData)
const data = await apiPut<Tag> (`/tags/${ id }`, formData, { locale })
setName (data.name)
setCategory (data.category as Category)
@@ -70,12 +77,13 @@ const TagDetailPage: FC = () => {
qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root })
toast ({ description: '更新しました.' })
qc.invalidateQueries ({ queryKey: wikiKeys.root })
toast ({ description: label ('shared:messages.updated') })
}
catch (e)
{
applyValidationError (e)
toast ({ description: '更新に失敗しました.' })
toast ({ description: label ('shared:updateFailedMessage') })
}
}
@@ -94,9 +102,13 @@ const TagDetailPage: FC = () => {
setDisabled (tag.category === 'nico')
}, [tag])
if ((!loading && tag == null) || (isApiError (error) && error.response?.status === 404))
return <NotFound/>
return (
<MainArea>
{(loading || !(tag)) ? 'Loading...' : (
<PageHead title={tag?.name ?? message ('title')}/>
{(loading || !(tag)) ? label ('common:loading') : (
<div className="max-w-xl">
<PageTitle>
<TagLink
@@ -109,7 +121,7 @@ const TagDetailPage: FC = () => {
<FieldError messages={baseErrors}/>
{/* 名称 */}
<FormField label="名称" messages={fieldErrors.name}>
<FormField label={label ('history:name')} messages={fieldErrors.name}>
{({ describedBy, invalid }) => (
<>
{/* TODO: 補完に対応させる */}
@@ -125,7 +137,7 @@ const TagDetailPage: FC = () => {
</FormField>
{/* カテゴリ */}
<FormField label="カテゴリ" messages={fieldErrors.category}>
<FormField label={label ('common:categoryLabel')} messages={fieldErrors.category}>
{({ describedBy, invalid }) => (
<select
disabled={disabled}
@@ -136,14 +148,15 @@ const TagDetailPage: FC = () => {
className={inputClass (invalid)}>
{CATEGORIES.filter (cat => tag.category === 'nico' || cat !== 'nico')
.map (cat => (
<option key={cat} value={cat}>
{CATEGORY_NAMES[cat]}
<option key={cat} value={cat}
lang={message (`common:categories.${cat}`).language}>
{message (`common:categories.${cat}`).text}
</option>))}
</select>)}
</FormField>
{/* 別名 */}
<FormField label="別名" messages={fieldErrors.aliases}>
<FormField label={label ('aliases')} messages={fieldErrors.aliases}>
{({ describedBy, invalid }) => (
<>
{/* TODO: 補完に対応させる */}
@@ -159,7 +172,7 @@ const TagDetailPage: FC = () => {
</FormField>
{/* 上位タグ */}
<FormField label="上位タグ" messages={fieldErrors.parentTags}>
<FormField label={label ('parents')} messages={fieldErrors.parentTags}>
{({ describedBy, invalid }) => (
<>
{/* TODO: 補完に対応させる */}
@@ -174,7 +187,7 @@ const TagDetailPage: FC = () => {
</>)}
</FormField>
<FormField label="廃止済" messages={fieldErrors.deprecated}>
<FormField label={label ('deprecated')} messages={fieldErrors.deprecated}>
{({ describedBy, invalid }) => (
<input
type="checkbox"
@@ -193,7 +206,7 @@ const TagDetailPage: FC = () => {
(disabled
? 'text-gray-300 bg-gray-500'
: 'text-white bg-blue-500'))}>
更新
{label ('shared:actions.update')}
</button>
</div>
</form>
+353 -138
ファイルの表示
@@ -1,50 +1,246 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { Helmet } from 'react-helmet-async'
import { useEffect, useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation } from 'react-router-dom'
import { useLocale } from '@/lib/useLocale'
import PrefetchLink from '@/components/PrefetchLink'
import LocalisedText from '@/components/LocalisedText'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { CATEGORY_NAMES } from '@/consts'
import PageHead from '@/components/common/PageHead'
import { apiPut } from '@/lib/api'
import { useDialogue } from '@/lib/dialogues/useDialogue'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { localeLanguage } from '@/lib/locales'
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTagChanges } from '@/lib/tags'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn, dateString } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import { cn, localisedDateString } from '@/lib/utils'
import type { FC } from 'react'
import type { FC, ReactNode } from 'react'
import type { TagNameSnapshot, TagVersion } from '@/types'
const renderDiff = (diff: { current: string | null; prev: string | null }) => (
const normaliseNameSnapshot = (value: unknown): TagNameSnapshot | null => {
if (value == null || typeof value !== 'object' || Array.isArray (value))
return null
const entry = value as Record<string, unknown>
const automatic = entry.autoGenerated ?? entry.auto_generated
const script = entry.scriptCode ?? entry.script_code
const aliases = Array.isArray (entry.aliases) ? entry.aliases : []
return {
name: typeof entry.name === 'string' ? entry.name : null,
scriptCode: typeof script === 'string' ? script : null,
autoGenerated: typeof automatic === 'boolean' ? automatic : null,
aliases: aliases.flatMap<TagNameSnapshot['aliases'][number]> (alias => {
if (typeof alias === 'string')
return [{ name: alias, scriptCode: null }]
if (alias == null || typeof alias !== 'object' || typeof alias.name !== 'string')
return []
const aliasScript = alias.scriptCode ?? alias.script_code
return [{ name: alias.name,
scriptCode: typeof aliasScript === 'string' ? aliasScript : null }]
}) }
}
const snapshotNames = (names: TagVersion['names']): Record<string, TagNameSnapshot> => {
try
{
const entries = typeof names === 'string' ? JSON.parse (names) : names
if (entries == null || typeof entries !== 'object' || Array.isArray (entries))
return { }
return Object.fromEntries (Object.entries (entries).flatMap (([language, value]) => {
const snapshot = normaliseNameSnapshot (value)
return snapshot == null ? [] : [[language, snapshot]]
}))
}
catch
{
return { }
}
}
const renderPrimaryName = (
snapshot: Pick<TagNameSnapshot, 'name' | 'autoGenerated'>,
language: string,
label: (key: string) => ReactNode,
) => (
<span lang={language} className="whitespace-normal [overflow-wrap:anywhere]">
{snapshot.name}
{snapshot.name != null && snapshot.autoGenerated && (
<span className="ainu-ui-label inline-block whitespace-nowrap">
{label ('nameChanges.uncertified')}
</span>)}
</span>)
const renderAliasDiff = (
previous: TagNameSnapshot | null,
current: TagNameSnapshot | null,
language: string,
) => {
const before = new Set (previous?.aliases.map (alias => alias.name) ?? [])
const after = new Set (current?.aliases.map (alias => alias.name) ?? [])
return [...new Set ([...after, ...before])].map (name => {
if (!(before.has (name)))
{
return (
<ins
key={name}
lang={language}
className="mr-2 inline-block max-w-full whitespace-normal [overflow-wrap:anywhere]
underline text-green-600 dark:text-green-400">
{name}
</ins>)
}
if (!(after.has (name)))
{
return (
<del
key={name}
lang={language}
className="mr-2 inline-block max-w-full whitespace-normal [overflow-wrap:anywhere]
line-through text-red-600 dark:text-red-400">
{name}
</del>)
}
return (
<span
key={name}
lang={language}
className="mr-2 inline-block max-w-full whitespace-normal [overflow-wrap:anywhere]">
{name}
</span>)
})
}
const renderDiff = <T,> (
diff: { current: T | null; prev: T | null },
format: (value: T) => ReactNode,
equal: (prev: T | null, current: T | null) => boolean = Object.is,
) => (
<>
{diff.prev !== diff.current
{!(equal (diff.prev, diff.current))
? (
<>
<del className="text-red-600 dark:text-red-400">
{diff.prev && <>{diff.prev}<br/></>}
{diff.prev != null && diff.prev !== '' && <>{format (diff.prev)}<br/></>}
</del>
<ins className="text-green-600 dark:text-green-400">
{diff.current}
{diff.current != null && format (diff.current)}
</ins>
</>)
: diff.current}
: diff.current != null && format (diff.current)}
</>)
const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : ''
const TagVersionRows = ({ change, children }: {
change: TagVersion
children: (primary: ReactNode) => ReactNode }) => {
const locale = useLocale ()
const language = localeLanguage (locale)
const { message, label } = useUITranslation ('history')
const { i18n } = useTranslation ('history')
const panelId = useId ()
const names = snapshotNames (change.names)
const nameChanges = (change.nameChanges ?? []).map (entry => ({
...entry,
current: normaliseNameSnapshot (entry.current),
prev: normaliseNameSnapshot (entry.prev) }))
const selected = nameChanges.find (entry => entry.languageCode === language)
const legacyName = (name: string | null): TagNameSnapshot | null =>
name == null ? null : {
name, autoGenerated: null, scriptCode: null, aliases: [] }
const current =
selected != null
? selected.current
: names[language] ?? legacyName (change.name.current)
const previous =
selected != null
? selected.prev
: ((change.nameChanges != null && change.name.prev === change.name.current)
? current : legacyName (change.name.prev))
const missingNameRecord = current?.name == null && previous?.name == null
const otherLanguages = [...new Set ([...Object.keys (names),
...nameChanges.map (entry => entry.languageCode)])]
.filter (code => code !== language).sort ()
const otherChanges = nameChanges.filter (entry => entry.languageCode !== language)
const initiallyExpanded = missingNameRecord || otherChanges.length > 0
const [expansion, setExpansion] = useState ({ language, expanded: initiallyExpanded })
const expanded = expansion.language === language ? expansion.expanded : initiallyExpanded
const rowIds = otherLanguages.map ((_, index) => `${panelId}-${index}`)
const primaryEqual = (prev: TagNameSnapshot | null, next: TagNameSnapshot | null) =>
prev?.name === next?.name && prev?.autoGenerated === next?.autoGenerated
const primary =
missingNameRecord
? null
: renderDiff ({ current, prev: previous },
snapshot => renderPrimaryName (snapshot, language, label), primaryEqual)
const toggle = otherLanguages.length > 0 && (
<div className="text-sm">
<button type="button" className="text-left"
aria-expanded={expanded} aria-controls={rowIds.join (' ')}
onClick={() => setExpansion ({ language, expanded: !(expanded) })}>
{label ('nameChanges.otherLanguages')}
{otherChanges.length > 0 && label ('nameChanges.changed')}
<span aria-hidden="true" className="ml-1">{expanded ? '▲' : '▼'}</span>
</button>
</div>)
const renderStateDiff = (diff: { current: string | null; prev: string | null }) =>
renderDiff ({ current: tagStateLabel (diff.current),
prev: tagStateLabel (diff.prev) })
return (
<tbody className="even:bg-gray-100 even:text-gray-900
dark:even:bg-gray-700 dark:even:text-gray-100">
{children (primary)}
{toggle && (
<tr>
<td colSpan={8} className={cn ('px-2 pt-0', !expanded && 'pb-2')}>
{toggle}
</td>
</tr>)}
{otherLanguages.map ((code, index) => {
const entry = nameChanges.find (value => value.languageCode === code)
const key = `languageNames.${code}`
const known = i18n.exists (key, { ns: 'history', lng: locale })
const next = entry != null ? entry.current : names[code] ?? null
const prev = entry != null ? entry.prev : next
return (
<tr key={code} id={rowIds[index]} hidden={!expanded}>
<th scope="row" className="px-2 pt-1 pb-2 text-left text-sm font-normal">
<LocalisedText language={known ? code : 'und'}>
{known ? message (key).text : code}
</LocalisedText>
</th>
<td className="px-2 pt-1 pb-2">
{renderDiff ({ current: next, prev },
value => renderPrimaryName (value, code, label), primaryEqual)}
</td>
<td/>
<td/>
<td className="px-2 pt-1 pb-2">{renderAliasDiff (prev, next, code)}</td>
<td/>
<td/>
<td/>
</tr>)
})}
</tbody>)
}
const TagHistoryPage: FC = () => {
const { message, label } = useUITranslation ('history')
const dialogue = useDialogue ()
const locale = useLocale ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const location = useLocation ()
@@ -53,9 +249,9 @@ const TagHistoryPage: FC = () => {
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
const { data, isLoading: loading } = useQuery ({
queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }),
queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }) })
const { data, isLoading: loading, isError } = useQuery ({
queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }, locale),
queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }, locale) })
const changes = data?.versions ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
@@ -68,16 +264,18 @@ const TagHistoryPage: FC = () => {
return (
<MainArea>
<Helmet>
<title>{`タグ定義変更履歴 | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('tagTitle')}/>
<PageTitle>
タグ定義変更履歴
{id && <>: タグ {<PrefetchLink to={`/tags/${ id }`}>#{id}</PrefetchLink>}</>}
{label ('tagTitle')}
{id && (
<>{label ('tagPrefix')}
<PrefetchLink to={`/tags/${ id }`}>#{id}</PrefetchLink>
</>)}
</PageTitle>
{loading ? 'Loading...' : (
{isError && <p role="alert">{label ('common:fetchError')}</p>}
{loading ? label ('common:loading') : (
<>
<div className="overflow-x-auto">
<table className="w-full min-w-[1200px] table-fixed border-collapse">
@@ -102,130 +300,147 @@ const TagHistoryPage: FC = () => {
<thead className="border-b-2 border-black dark:border-white">
<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">別名</th>
<th className="p-2 text-left">上位タグ</th>
<th className="p-2 text-left">更新日時</th>
<th className="p-2 text-left">{label ('version')}</th>
<th className="p-2 text-left">{label ('name')}</th>
<th className="p-2 text-left">{label ('common:categoryLabel')}</th>
<th className="p-2 text-left">{label ('common:stateLabel')}</th>
<th className="p-2 text-left">{label ('tags:aliases')}</th>
<th className="p-2 text-left">{label ('tags:parents')}</th>
<th className="p-2 text-left">{label ('tags:updatedAt')}</th>
<th className="p-2"/>
</tr>
</thead>
<tbody>
{changes.map (change => (
<tr key={`${ change.tagId }.${ change.versionNo }`}
className={cn ('even:bg-gray-100 dark:even:bg-gray-700')}>
<td className="p-2">{change.tagId}.{change.versionNo}</td>
<td className="p-2 break-all">{renderDiff (change.name)}</td>
<td className="p-2 break-all">
{renderDiff ({
current: CATEGORY_NAMES[change.category.current],
prev: (change.category.prev
&& CATEGORY_NAMES[change.category.prev]) })}
</td>
<td className="p-2 break-all">
{renderStateDiff (change.deprecatedAt)}
</td>
<td className="p-2">
{change.aliases.map ((tag, i) => (
tag.type === 'added'
? (
<ins
key={i}
className="mr-2 text-green-600 dark:text-green-400">
{tag.name}
</ins>)
: (
tag.type === 'removed'
{changes.map (change => (
<TagVersionRows key={`${ change.tagId }.${ change.versionNo }`} change={change}>
{primary => (
<tr className="[&>td]:pt-1">
<td className="p-2">{change.tagId}.{change.versionNo}</td>
<td className="p-2 break-all">
{primary}
</td>
<td className="p-2 break-all">
{renderDiff (change.category,
category => label (`common:categories.${category}`))}
</td>
<td className="p-2 break-all">
{renderDiff ({
current: change.deprecatedAt.current ? 'deprecated' : '',
prev: change.deprecatedAt.prev ? 'deprecated' : '' },
state => state ? label (`common:states.${state}`) : '')}
</td>
<td className="p-2">
{change.aliases.map ((tag, i) => (
tag.type === 'added'
? (
<del
<ins
key={i}
className="mr-2 text-red-600 dark:text-red-400">
className="mr-2 text-green-600 dark:text-green-400">
{tag.name}
</del>)
</ins>)
: (
<span key={i} className="mr-2">
{tag.name}
</span>))))}
</td>
<td className="p-2">
{change.parentTags.map ((tag, i) => (
tag.type === 'added'
? (
<ins
key={i}
className="mr-2 text-green-600 dark:text-green-400">
{tag.tag.name}
</ins>)
: (
tag.type === 'removed'
tag.type === 'removed'
? (
<del
key={i}
className="mr-2 text-red-600 dark:text-red-400">
{tag.name}
</del>)
: (
<span key={i} className="mr-2">
{tag.name}
</span>))))}
</td>
<td className="p-2">
{change.parentTags.map ((tag, i) => (
tag.type === 'added'
? (
<del
<ins
key={i}
className="mr-2 text-red-600 dark:text-red-400">
className="mr-2 text-green-600 dark:text-green-400">
{tag.tag.name}
</del>)
</ins>)
: (
<span key={i} className="mr-2">
{tag.tag.name}
</span>))))}
</td>
<td className="p-2">
{change.createdByUser
? (
<PrefetchLink to={`/users/${ change.createdByUser.id }`}>
{change.createdByUser.name
|| `名もなきニジラー(#${ change.createdByUser.id })`}
</PrefetchLink>)
: 'bot 操作'}
<br/>
{dateString (change.createdAt)}
</td>
<td className="p-2">
<a
href="#"
onClick={async e => {
e.preventDefault ()
if (!(confirm (
`タグ『${ change.name.current }』を版 ${
change.versionNo } に差戻します.\nよろしいですか?`)))
return
try
{
await apiPut (
`/tags/${ change.tagId }`,
{ name: change.name.current,
category: change.category.current,
deprecated: change.deprecatedAt.current ? '1' : '0',
aliases:
change.aliases
.filter (t => t.type !== 'removed')
.map (t => t.name)
.join (' '),
parent_tags:
change.parentTags
.filter (t => t.type !== 'removed')
.map (t => t.tag.name)
.join (' ') })
qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root })
toast ({ description: '差戻しました.' })
}
catch
{
toast ({ description: '差戻に失敗……' })
}
}}>
復元
</a>
</td>
</tr>))}
</tbody>
tag.type === 'removed'
? (
<del
key={i}
className="mr-2 text-red-600 dark:text-red-400">
{tag.tag.name}
</del>)
: (
<span key={i} className="mr-2">
{tag.tag.name}
</span>))))}
</td>
<td className="p-2">
{change.createdByUser
? (
<PrefetchLink to={`/users/${ change.createdByUser.id }`}>
{change.createdByUser.name
|| label ('anonymous', { id: change.createdByUser.id })}
</PrefetchLink>)
: label ('bot')}
<br/>
{localisedDateString (change.createdAt, locale)}
</td>
<td className="p-2">
<a
href="#"
onClick={async e => {
e.preventDefault ()
if (!(await dialogue.confirm ({
title: label ('confirmTitle'),
description: (
<>
{label ('confirmTag', {
name: change.name.current ?? '', version: change.versionNo })}
<br/>
{label ('restoreScope')}
</>),
confirmText: label ('revert'),
cancelText: label ('settings:actions.cancel'),
closeLabel: label ('settings:actions.close') })))
return
try
{
if (!(change.name.current)
|| change.parentTags.some (tag =>
tag.type !== 'removed' && !(tag.tag.name)))
throw new Error ()
await apiPut (
`/tags/${ change.tagId }`,
{ name: change.name.current,
category: change.category.current,
deprecated: change.deprecatedAt.current ? '1' : '0',
aliases:
change.aliases
.filter (t => t.type !== 'removed')
.map (t => t.name)
.join (' '),
parent_tags:
change.parentTags
.filter (t => t.type !== 'removed')
.map (t => t.tag.name)
.join (' ') }, { locale })
qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root })
qc.invalidateQueries ({ queryKey: wikiKeys.root })
toast ({ description: label ('restored') })
}
catch
{
toast ({ description: label ('restoreFailed') })
}
}}>
{label ('shared:actions.restore')}
</a>
</td>
</tr>)}
</TagVersionRows>))}
</table>
</div>
+9 -9
ファイルの表示
@@ -48,12 +48,12 @@ describe ('TagListPage', () => {
order: 'post_count:desc',
postCountGTE: 5,
deprecated: true,
}),
}), 'ja',
)
})
expect (await screen.findByRole ('link', { name: '虹夏' })).toHaveAttribute (
'href',
'/tags/7',
'/ja/tags/7',
)
expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0)
expect (screen.getAllByRole ('combobox')[2]).toHaveValue ('1')
@@ -77,7 +77,7 @@ describe ('TagListPage', () => {
name: '虹夏',
category: 'character',
page: 1,
}),
}), 'ja',
)
})
expect (screen.getByRole ('textbox')).toHaveValue ('虹夏')
@@ -104,7 +104,7 @@ describe ('TagListPage', () => {
await waitFor (() => {
expect (tagsApi.fetchTags).toHaveBeenCalledWith (
expect.objectContaining ({ limit: 50, order: 'updated_at:desc' }),
expect.objectContaining ({ limit: 50, order: 'updated_at:desc' }), 'ja',
)
})
expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}'))
@@ -123,7 +123,7 @@ describe ('TagListPage', () => {
await waitFor (() => {
expect (tagsApi.fetchTags).toHaveBeenLastCalledWith (
expect.objectContaining ({ page: 1, limit: 100 }),
expect.objectContaining ({ page: 1, limit: 100 }), 'ja',
)
})
})
@@ -152,10 +152,10 @@ describe ('TagListPage', () => {
const externalLink =
screen.getByRole ('link', { name: 'nico:external_collision' })
expect (internalLink).toHaveAttribute ('href', '/tags/7')
expect (internalLink).toHaveAttribute ('href', '/ja/tags/7')
expect (externalLink).toHaveAttribute (
'href',
'/tags/nico?name=nico%3Aexternal_collision',
'/ja/tags/nico?name=nico%3Aexternal_collision',
)
const internalRow = internalLink.closest ('tr')
@@ -166,10 +166,10 @@ describe ('TagListPage', () => {
expect (
within (internalRow!).getByRole ('link', { name: '耕作履歴' }),
).toHaveAttribute ('href', '/posts/changes?tag=7')
).toHaveAttribute ('href', '/ja/posts/changes?tag=7')
expect (
within (externalRow!).getByRole ('link', { name: '耕作履歴' }),
).toHaveAttribute ('href', '/posts/changes?external_tag=7')
).toHaveAttribute ('href', '/ja/posts/changes?external_tag=7')
})
})
+119 -75
ファイルの表示
@@ -1,8 +1,9 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import LocalisedText from '@/components/LocalisedText'
import PrefetchLink from '@/components/PrefetchLink'
import SortHeader from '@/components/SortHeader'
import TagLink from '@/components/TagLink'
@@ -11,8 +12,8 @@ import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import PageHead from '@/components/common/PageHead'
import { CATEGORIES } from '@/consts'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { tagsKeys } from '@/lib/queryKeys'
import {
@@ -25,8 +26,10 @@ import {
} from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { useUITranslation } from '@/lib/useUITranslation'
import { useHydrated } from '@/lib/useHydrated'
import { fetchTags } from '@/lib/tags'
import { dateString, inputClass } from '@/lib/utils'
import { inputClass, localisedDateString } from '@/lib/utils'
import type { FC, FormEvent } from 'react'
@@ -44,8 +47,6 @@ const boolFromQuery = (value: string | null): boolean =>
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ())
const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : ''
const parseLimit = (value: string | null): 20 | 50 | 100 | null => {
const n = Number (value)
return n === 20 || n === 50 || n === 100 ? n : null
@@ -73,18 +74,21 @@ const parseOrder = (value: string | null): FetchTagsOrder | null => {
const TagListPage: FC = () => {
const locale = useLocale ()
const hydrated = useHydrated ()
const { message, label } = useUITranslation ('tags')
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const location = useLocation ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const query = useMemo (() => new URLSearchParams (location.search), [location.search])
const page = Number (query.get ('page') ?? 1)
const limit = (
parseLimit (query.get ('limit'))
?? getClientListLimit ('tagList')
?? (hydrated ? getClientListLimit ('tagList') : null)
?? DEFAULT_POST_LIST_LIMIT
)
@@ -97,12 +101,13 @@ const TagListPage: FC = () => {
const qCreatedTo = query.get ('created_to') ?? ''
const qUpdatedFrom = query.get ('updated_from') ?? ''
const qUpdatedTo = query.get ('updated_to') ?? ''
const qDeprecated = query.has ('deprecated')
const qDeprecated =
query.has ('deprecated')
? boolFromQuery (query.get ('deprecated'))
: null
const order = (
parseOrder (query.get ('order'))
?? getClientListOrder<FetchTagsOrder> ('tagList')
?? (hydrated ? getClientListOrder<FetchTagsOrder> ('tagList') : null)
?? DEFAULT_TAG_LIST_ORDER
)
@@ -128,15 +133,17 @@ const TagListPage: FC = () => {
updatedFrom: qUpdatedFrom,
updatedTo: qUpdatedTo,
deprecated: qDeprecated }
const { data, isLoading: loading } = useQuery ({
queryKey: tagsKeys.index (keys),
queryFn: () => fetchTags (keys) })
const { data, isLoading: loading, isError } = useQuery ({
queryKey: tagsKeys.index (keys, locale),
queryFn: () => fetchTags (keys, locale) })
const results = data?.tags ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
if (!hydrated)
return
setClientListSettings ('tagList', { limit, order })
}, [limit, order])
}, [hydrated, limit, order])
useEffect (() => {
setName (qName)
@@ -201,32 +208,26 @@ const TagListPage: FC = () => {
useKeyboardShortcuts (useMemo (() => ({
...(page > 1
? {
'pagination.previous': () => {
? { 'pagination.previous': () => {
updatePage (page - 1)
},
}
} }
: { }),
...(totalPages > 0 && page < totalPages
? {
'pagination.next': () => {
? { 'pagination.next': () => {
updatePage (Math.min (page + 1, totalPages))
},
}
} }
: { }),
}), [page, totalPages, location.search]))
return (
<MainArea>
<Helmet>
<title>タグ | {SITE_TITLE}</title>
</Helmet>
<MainArea className="min-w-0">
<PageHead title={message ('title')}/>
<div className="max-w-xl">
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle>タグ</PageTitle>
<PageTitle>{label ('title')}</PageTitle>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>件数</span>
<span>{label ('common:countLabel')}</span>
<select
value={limit}
onChange={ev => updateListQuery ({
@@ -244,7 +245,7 @@ const TagListPage: FC = () => {
<form onSubmit={handleSearch} className="space-y-2">
{/* 名前 */}
<FormField label="名前">
<FormField label={label ('name')}>
{({ invalid }) => (
<input
type="text"
@@ -255,7 +256,7 @@ const TagListPage: FC = () => {
</FormField>
{/* カテゴリ */}
<FormField label="カテゴリ">
<FormField label={label ('common:categoryLabel')}>
{({ invalid }) => (
<select
value={category ?? ''}
@@ -263,14 +264,17 @@ const TagListPage: FC = () => {
className={inputClass (invalid)}>
<option value="">&nbsp;</option>
{CATEGORIES.map (cat => (
<option key={cat} value={cat}>
{CATEGORY_NAMES[cat]}
<option
key={cat}
value={cat}
lang={message (`common:categories.${ cat }`).language}>
{message (`common:categories.${ cat }`).text}
</option>))}
</select>)}
</FormField>
{/* 広場の投稿数 */}
<FormField label="広場の投稿数">
<FormField label={label ('postCount')}>
{({ invalid }) => (
<>
<input
@@ -292,34 +296,38 @@ const TagListPage: FC = () => {
</FormField>
{/* はじめて記載された日時 */}
<FormField label="はじめて記載された日時">
<FormField label={label ('firstRecordedAt')}>
{() => (
<>
<DateTimeField
lang={locale}
value={createdFrom ?? undefined}
onChange={setCreatedFrom}/>
<span className="mx-1">〜</span>
<DateTimeField
lang={locale}
value={createdTo ?? undefined}
onChange={setCreatedTo}/>
</>)}
</FormField>
{/* 定義の更新日時 */}
<FormField label="定義の更新日時">
<FormField label={label ('definitionUpdatedAt')}>
{() => (
<>
<DateTimeField
lang={locale}
value={updatedFrom ?? undefined}
onChange={setUpdatedFrom}/>
<span className="mx-1">〜</span>
<DateTimeField
lang={locale}
value={updatedTo ?? undefined}
onChange={setUpdatedTo}/>
</>)}
</FormField>
<FormField label="状態">
<FormField label={label ('common:stateLabel')}>
{({ invalid }) => (
<select
value={deprecated == null ? '' : (deprecated ? '1' : '0')}
@@ -329,8 +337,12 @@ const TagListPage: FC = () => {
: e.target.value === '1')}
className={inputClass (invalid)}>
<option value="">&nbsp;</option>
<option value="0">有効</option>
<option value="1">廃止</option>
<option value="0" lang={message ('common:states.active').language}>
{message ('common:states.active').text}
</option>
<option value="1" lang={message ('common:states.deprecated').language}>
{message ('common:states.deprecated').text}
</option>
</select>)}
</FormField>
@@ -338,16 +350,17 @@ const TagListPage: FC = () => {
<button
type="submit"
className="bg-blue-500 text-white px-4 py-2 rounded">
検索
{label ('common:search')}
</button>
</div>
</form>
</div>
{loading ? 'Loading...' : (results.length > 0 ? (
<div className="mt-4">
<div className="overflow-x-auto">
<table className="w-full min-w-[2000px] table-fixed border-collapse">
{isError && <p role="alert">{label ('common:fetchError')}</p>}
{loading ? label ('common:loading') : (results.length > 0 ? (
<div className="mt-4 min-w-0 max-w-full">
<div className="min-w-0 max-w-full overflow-x-auto">
<table className="w-full min-w-[2000px] table-auto border-collapse">
<colgroup>
<col className="w-72"/>
<col className="w-16"/>
@@ -359,44 +372,50 @@ const TagListPage: FC = () => {
<col className="w-56"/>
<col className="w-16"/>
</colgroup>
<thead className="border-b-2 border-black dark:border-white">
<thead className="border-b-2 border-black dark:border-white
[&_.ainu-ui-text]:!whitespace-nowrap [&_wbr]:hidden">
<tr>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchTagsOrderField>
by="name"
label="タグ"
label={label ('title')}
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchTagsOrderField>
by="post_count"
label="件数"
label={label ('common:countLabel')}
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchTagsOrderField>
by="category"
label="カテゴリ"
label={label ('common:categoryLabel')}
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">状態</th>
<th className="p-2 text-left whitespace-nowrap">別名</th>
<th className="p-2 text-left whitespace-nowrap">上位タグ</th>
<th className="p-2 text-left whitespace-nowrap">
{label ('common:stateLabel')}
</th>
<th className="p-2 text-left whitespace-nowrap">
{label ('aliases')}
</th>
<th className="p-2 text-left whitespace-nowrap">
{label ('parents')}
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchTagsOrderField>
by="created_at"
label="最初の記載日時"
label={label ('firstRecordedColumn')}
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchTagsOrderField>
by="updated_at"
label="更新日時"
label={label ('updatedAt')}
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
@@ -410,34 +429,59 @@ const TagListPage: FC = () => {
key={`${ row.category }:${ row.id }`}
className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">
<TagLink
tag={row}
to={row.category === 'nico'
? `/tags/nico?name=${ encodeURIComponent (row.name) }`
: `/tags/${ encodeURIComponent (row.id) }`}
withCount={false}/>
<div className="max-w-[24rem]">
<TagLink
tag={row}
to={row.category === 'nico'
? `/tags/nico?name=${ encodeURIComponent (row.name) }`
: `/tags/${ encodeURIComponent (row.id) }`}
withCount={false}/>
</div>
</td>
<td className="p-2 text-right">{row.postCount}</td>
<td className="p-2">{CATEGORY_NAMES[row.category]}</td>
<td className="p-2">{tagStateLabel (row.deprecatedAt)}</td>
<td className="p-2">{row.aliases.join (' ')}</td>
<td className="p-2">
{row.parents.map (t => (
<span key={t.id} className="mr-2">
<TagLink
tag={t}
withWiki={false}
withCount={false}/>
</span>))}
{label (`common:categories.${ row.category }`)}
</td>
<td className="p-2">
{row.deprecatedAt ? label ('common:states.deprecated') : ''}
</td>
<td className="p-2">
<div className="max-w-[24rem] [overflow-wrap:anywhere]">
{row.aliases.join (' ')}
</div>
</td>
<td className="p-2">
<div className="max-w-[24rem]">
{row.parents.map (t => (
<span key={t.id} className="mr-2 inline-block max-w-full">
<TagLink
tag={t}
withWiki={false}
withCount={false}/>
</span>))}
</div>
</td>
<td className="p-2">
<time dateTime={row.createdAt} lang={locale}>
<LocalisedText language={locale}>
{localisedDateString (row.createdAt, locale)}
</LocalisedText>
</time>
</td>
<td className="p-2">
<time dateTime={row.updatedAt} lang={locale}>
<LocalisedText language={locale}>
{localisedDateString (row.updatedAt, locale)}
</LocalisedText>
</time>
</td>
<td className="p-2">{dateString (row.createdAt)}</td>
<td className="p-2">{dateString (row.updatedAt)}</td>
<td className="p-2">
<PrefetchLink
to={row.category === 'nico'
? `/posts/changes?external_tag=${ row.id }`
: `/posts/changes?tag=${ row.id }`}>
耕作履歴
: `/posts/changes?tag=${ row.id }`}
className="ainu-ui-label inline-block whitespace-nowrap">
{label ('history')}
</PrefetchLink>
</td>
</tr>))}
@@ -446,7 +490,7 @@ const TagListPage: FC = () => {
</div>
<Pagination page={page} totalPages={totalPages}/>
</div>) : '結果ないよ(笑)')}
</div>) : (isError ? null : label ('empty')))}
</MainArea>)
}
+41 -25
ファイルの表示
@@ -1,8 +1,10 @@
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { QueryClient } from '@tanstack/react-query'
import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
import { theatresKeys } from '@/lib/queryKeys'
import { buildPost,
buildTag,
buildTheatre,
@@ -40,10 +42,10 @@ const postEmbed = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
vi.mock ('@/lib/dialogues/useDialogue', async importOriginal => ({
...await importOriginal<
typeof import('@/components/dialogues/DialogueProvider')> (),
useDialogue: () => dialogue }))
typeof import('@/lib/dialogues/useDialogue')> (),
default: () => dialogue }))
vi.mock ('@/components/PostEmbed', () => ({
default: (props: {
ref?: { current: unknown }
@@ -94,19 +96,28 @@ const watchingUsers = [
{ id: 2, name: 'another' },
]
const renderPage = (user = buildUser ({ id: 1, role: 'member' })) =>
renderWithProviders (
const renderPage = (
user = buildUser ({ id: 1, role: 'member' }),
prefetched = true,
) => {
const queryClient = new QueryClient ({
defaultOptions: { queries: { retry: false, staleTime: Infinity } } })
if (prefetched)
{
queryClient.setQueryData (theatresKeys.show ('7', 'ja'), theatre)
queryClient.setQueryData (theatresKeys.programmes ('7', 'ja'), [programme])
}
return renderWithProviders (
<Routes>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
</Routes>,
{ route: '/theatres/7' },
)
{ route: '/theatres/7', queryClient })
}
const tagSection = (): HTMLElement =>
screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')!
const mockDefaultApi = () => {
let commentsFetched = false
let currentTheatreInfo = buildTheatreInfo ({
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
@@ -120,17 +131,16 @@ const mockDefaultApi = () => {
},
})
api.apiGet.mockImplementation ((path: string) => {
api.apiGet.mockImplementation ((path: string, options?: { params?: { no_gt?: number } }) => {
switch (path)
{
case '/theatres/7':
return Promise.resolve (theatre)
case '/theatres/7/comments':
if (commentsFetched)
if ((options?.params?.no_gt ?? 0) >= 2)
return Promise.resolve ([])
commentsFetched = true
return Promise.resolve ([
buildTheatreComment ({
theatreId: 7,
@@ -192,30 +202,32 @@ describe ('TheatreDetailPage', () => {
})
it ('loads theatre state, comments, current post, programme history, and weights', async () => {
renderPage ()
renderPage (undefined, false)
expect (await screen.findByText ('上映会場『上映室』')).toBeInTheDocument ()
expect (await screen.findByText ('Embed:上映中の投稿')).toBeInTheDocument ()
expect (screen.getAllByText ('視聴コメント')[0]).toBeInTheDocument ()
expect (await screen.findByText ('Embed:上映中の投稿', {}, { timeout: 4_000 })).toBeInTheDocument ()
expect ((await screen.findAllByText ('視聴コメント', {}, { timeout: 4_000 }))[0])
.toBeInTheDocument ()
expect (screen.getAllByText ('上映中の投稿')[0]).toBeInTheDocument ()
expect (screen.getByText ('penalty 2')).toBeInTheDocument ()
await waitFor (() => {
expect (postsApi.fetchPost).toHaveBeenCalledWith ('10')
expect (postsApi.fetchPost).toHaveBeenCalledWith ('10', 'ja')
})
})
it ('votes to skip the current post', async () => {
renderPage ()
await screen.findByText ('Embed:上映中の投稿')
await screen.findByText ('Embed:上映中の投稿', {}, { timeout: 4_000 })
fireEvent.click (screen.getByRole ('button', { name: 'スキップ 0 / 2' }))
fireEvent.click (await screen.findByRole ('button',
{ name: 'スキップ 0 / 2' }, { timeout: 4_000 }))
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith (
'/theatres/7/skip_vote',
{ post_id: 10 },
{ post_id: 10 }, { locale: 'ja' },
)
})
expect (await screen.findByRole ('button', { name: 'スキップ取消 1 / 2' }))
@@ -247,7 +259,7 @@ describe ('TheatreDetailPage', () => {
})
renderPage ()
await screen.findByText ('Embed:上映中の投稿')
await screen.findByText ('Embed:上映中の投稿', {}, { timeout: 4_000 })
const props = postEmbed.props.mock.calls.at (-1)![0]
@@ -256,6 +268,7 @@ describe ('TheatreDetailPage', () => {
props.onPlaybackChange (0)
})
await waitFor (() => expect (postEmbed.seek).toHaveBeenCalled ())
const seekMs = postEmbed.seek.mock.calls[0][0]
expect (seekMs).toBeGreaterThanOrEqual (7_000)
expect (seekMs).toBeLessThan (10_000)
@@ -279,14 +292,14 @@ describe ('TheatreDetailPage', () => {
children: [childTag],
})
postsApi.fetchPost.mockResolvedValueOnce (buildPost ({
postsApi.fetchPost.mockResolvedValue (buildPost ({
...currentPost,
tags: [parentTag, duplicateParentTag],
}))
renderPage ()
await screen.findByText ('Embed:上映中の投稿')
await screen.findByText ('Embed:上映中の投稿', {}, { timeout: 4_000 })
expect (within (tagSection ()).getByRole ('link', { name: '親タグ' }))
.toBeInTheDocument ()
@@ -337,9 +350,9 @@ describe ('TheatreDetailPage', () => {
renderPage ()
await screen.findByText ('Embed:上映中の投稿')
await screen.findByText ('Embed:上映中の投稿', {}, { timeout: 4_000 })
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith ('/theatres/7/watching')
expect (api.apiPut).toHaveBeenCalledWith ('/theatres/7/watching', {}, { locale: 'ja' })
})
await waitFor (() => {
@@ -351,13 +364,16 @@ describe ('TheatreDetailPage', () => {
it ('deletes an owned comment after confirmation', async () => {
renderPage ()
fireEvent.click ((await screen.findAllByLabelText ('コメントを削除'))[0])
await screen.findByRole ('button', { name: 'スキップ 0 / 2' }, { timeout: 4_000 })
fireEvent.click ((await screen.findAllByLabelText (
'コメントを削除', {}, { timeout: 4_000 }))[0])
await waitFor (() => {
expect (dialogue.confirm).toHaveBeenCalled ()
})
await waitFor (() => {
expect (api.apiDelete).toHaveBeenCalledWith ('/theatres/7/comments/2')
expect (api.apiDelete).toHaveBeenCalledWith ('/theatres/7/comments/2', { locale: 'ja' })
})
expect (await screen.findAllByText ('削除されました.')).toHaveLength (2)
})
+154 -145
ファイルの表示
@@ -1,23 +1,26 @@
import { motion } from 'framer-motion'
import { useQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom'
import { useLocale } from '@/lib/useLocale'
import ErrorScreen from '@/components/ErrorScreen'
import PostEditForm from '@/components/PostEditForm'
import PostEmbed from '@/components/PostEmbed'
import PrefetchLink from '@/components/PrefetchLink'
import LocalisedText from '@/components/LocalisedText'
import TagLink from '@/components/TagLink'
import FieldError from '@/components/common/FieldError'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import useDialogue from '@/lib/dialogues/useDialogue'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { Button } from '@/components/ui/button'
import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import PageHead from '@/components/common/PageHead'
import { CATEGORIES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api'
import { clientAnimationTransition,
clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchPost } from '@/lib/posts'
import { theatresKeys } from '@/lib/queryKeys'
import {
getClientTheatreLayoutMode,
getClientTheatreTagFlow,
@@ -26,8 +29,9 @@ import {
} from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { canEditContent } from '@/lib/users'
import { cn, dateString, inputClass } from '@/lib/utils'
import { cn, localisedDateString, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, FormEvent, ReactNode } from 'react'
@@ -64,48 +68,13 @@ const MIN_MAIN_WIDTH = 360
const SIDEBAR_GAP_WIDTH = 16
const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = {
threeColumns: '3 列',
tagsBottom: '2 列 A 型',
commentsBottom: '2 列 B 型' }
threeColumns: 'layouts.threeColumns',
tagsBottom: 'layouts.tagsBottom',
commentsBottom: 'layouts.commentsBottom' }
const TAG_FLOW_LABELS: Record<TagFlow, string> = {
vertical: '縦並び',
horizontal: '横並び' }
const userName = (user: Pick<User, 'id' | 'name'> | null | undefined): string =>
user ? (user.name || `名もなきニジラー(#${ user.id })`) : '運営'
const commentBox = (
comment: TheatreComment,
programme: TheatreProgramme | null = null): ReactNode[] =>
[(
<div key={`${ comment.no }-content`} className="w-full">
{comment.deleted
? <span className="text-sm font-bold">削除されました.</span>
: comment.content}
</div>),
(
<div key={`${ comment.no }-user`} className="w-full text-sm text-right">
by {userName (comment.user)}
</div>),
(
<div key={`${ comment.no }-createdAt`} className="w-full text-sm text-right">
{dateString (comment.createdAt)}
</div>),
(
<div
key={`${ comment.no }-post`}
className="mt-1 w-full text-xs text-zinc-500 dark:text-zinc-400">
{programme && (
<>
<PrefetchLink to={`/posts/${ programme.post.id }`} className="font-bold hover:underline">
{programme.post.title || programme.post.url}
</PrefetchLink>
&thinsp;へのコメント
</>)}
</div>)]
vertical: 'flows.vertical',
horizontal: 'flows.horizontal' }
const compareTagName = (a: Tag, b: Tag): number =>
@@ -175,6 +144,7 @@ const renderReadonlyTagTree = (
const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
{ tags, compact, flow = 'vertical' }) => {
const { label } = useUITranslation ()
const horizontalGrouped = tagsByCategory (flattenTags (tags))
const verticalGrouped = tagsByCategory (tags)
@@ -199,7 +169,7 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
return (
<div key={cat}>
<div className="mb-1 shrink-0 text-xs font-bold text-zinc-500 dark:text-zinc-400">
{CATEGORY_NAMES[cat]}
{label (`categories.${cat}`)}
</div>
<ul className={cn ('space-y-1', compact && 'text-sm')}>
{rows.flatMap (tag =>
@@ -215,6 +185,37 @@ type Props = { user: User | null }
const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const locale = useLocale ()
const { message, label } = useUITranslation ('theatres')
const userName = (entry: Pick<User, 'id' | 'name'> | null | undefined): ReactNode =>
entry ? (entry.name || label ('history:anonymous', { id: entry.id })) : label ('staff')
const commentBox = (
comment: TheatreComment,
programme: TheatreProgramme | null = null,
): ReactNode[] => [
(<div key={`${comment.no}-content`} className="w-full">
{comment.deleted
? <span className="text-sm font-bold">{label ('deleted')}</span>
: comment.content}
</div>),
(<div key={`${comment.no}-user`} className="w-full text-sm text-right">
{label ('authorPrefix')}{userName (comment.user)}
</div>),
(<div key={`${comment.no}-createdAt`} className="w-full text-sm text-right">
<LocalisedText language={locale}>
{localisedDateString (comment.createdAt, locale)}
</LocalisedText>
</div>),
(<div key={`${comment.no}-post`}
className="mt-1 w-full text-xs text-zinc-500 dark:text-zinc-400">
{programme && (
<>
<PrefetchLink to={`/posts/${programme.post.id}`} className="font-bold hover:underline">
{programme.post.title || programme.post.url}
</PrefetchLink>
{label ('commentSuffix')}
</>)}
</div>)]
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
@@ -235,23 +236,30 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const [leftSidebarWidth, setLeftSidebarWidth] = useState (256)
const [rightSidebarWidth, setRightSidebarWidth] = useState (256)
const [sidebarContainerWidth, setSidebarContainerWidth] = useState (
() => typeof window === 'undefined' ? 0 : window.innerWidth)
const [sidebarContainerWidth, setSidebarContainerWidth] = useState (0)
const [comments, setComments] = useState<TheatreComment[]> ([])
const [content, setContent] = useState ('')
const [editingPost, setEditingPost] = useState<Post | null> (null)
const [loading, setLoading] = useState (false)
const [programmes, setProgrammes] = useState<TheatreProgramme[]> ([])
const { data: initialTheatre, error: theatreError } = useQuery ({
queryKey: theatresKeys.show (id ?? '', locale),
queryFn: () => apiGet<Theatre> (`/theatres/${ id }`, { locale }),
enabled: id != null })
const { data: initialProgrammes } = useQuery ({
queryKey: theatresKeys.programmes (id ?? '', locale),
queryFn: () => apiGet<TheatreProgramme[]> (`/theatres/${ id }/programmes`, {
locale, params: { limit: '100' } }), enabled: id != null })
const [programmes, setProgrammes] = useState<TheatreProgramme[]> (initialProgrammes ?? [])
const [sending, setSending] = useState (false)
const [status, setStatus] = useState (200)
const [theatre, setTheatre] = useState<Theatre | null> (null)
const status = isApiError (theatreError) ? theatreError.response?.status ?? 200 : 200
const [theatre, setTheatre] = useState<Theatre | null> (initialTheatre ?? null)
const [theatreInfo, setTheatreInfo] = useState<TheatreInfo> (INITIAL_THEATRE_INFO)
const [post, setPost] = useState<Post | null> (null)
const [videoLength, setVideoLength] = useState (0)
const [weights, setWeights] = useState<TheatrePostSelectionWeights> (INITIAL_WEIGHTS)
const [layoutMode, setLayoutMode] =
useState<TheatreLayoutMode> (() => getClientTheatreLayoutMode ())
const [tagFlow, setTagFlow] = useState<TagFlow> (() => getClientTheatreTagFlow ())
useState<TheatreLayoutMode> ('threeColumns')
const [tagFlow, setTagFlow] = useState<TagFlow> ('vertical')
const { fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<TheatreCommentField> ()
@@ -260,6 +268,11 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
setClientTheatreLayoutMode (mode)
}
useEffect (() => {
setLayoutMode (getClientTheatreLayoutMode ())
setTagFlow (getClientTheatreTagFlow ())
}, [])
const changeTagFlow = (flow: TagFlow) => {
setTagFlow (flow)
setClientTheatreTagFlow (flow)
@@ -286,16 +299,16 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
return
setProgrammes (await apiGet<TheatreProgramme[]> (
`/theatres/${ id }/programmes`, { params: { limit: '100' } }))
}, [id])
`/theatres/${ id }/programmes`, { locale, params: { limit: '100' } }))
}, [id, locale])
const refreshWeights = useCallback (async () => {
if (!(id))
return
setWeights (await apiGet<TheatrePostSelectionWeights> (
`/theatres/${ id }/post_selection_weights`))
}, [id])
`/theatres/${ id }/post_selection_weights`, { locale }))
}, [id, locale])
const advancePost = useCallback (async () => {
if (!(id))
@@ -304,7 +317,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
setLoading (true)
try
{
await apiPatch<void> (`/theatres/${ id }/next_post`)
await apiPatch<void> (`/theatres/${ id }/next_post`, { }, { locale })
await refreshProgrammes ()
await refreshWeights ()
}
@@ -316,7 +329,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
{
setLoading (false)
}
}, [id, refreshProgrammes, refreshWeights])
}, [id, refreshProgrammes, refreshWeights, locale])
useEffect (() => {
loadingRef.current = loading
@@ -349,39 +362,20 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
if (!(id))
return
let cancelled = false
setComments ([])
setEditingPost (null)
setPost (null)
setProgrammes ([])
setTheatre (null)
setProgrammes (initialProgrammes ?? [])
setTheatre (initialTheatre ?? null)
theatreInfoReceivedAtRef.current = performance.now ()
setTheatreInfo (INITIAL_THEATRE_INFO)
setVideoLength (0)
setWeights (INITIAL_WEIGHTS)
lastCommentNoRef.current = 0
void (async () => {
try
{
const data = await apiGet<Theatre> (`/theatres/${ id }`)
if (!(cancelled))
setTheatre (data)
}
catch (error)
{
setStatus (isApiError (error) ? error.response?.status ?? 200 : 200)
}
}) ()
void refreshProgrammes ()
void refreshWeights ()
return () => {
cancelled = true
}
}, [id, refreshProgrammes, refreshWeights])
}, [id, initialProgrammes, initialTheatre, refreshWeights, locale])
useEffect (() => {
if (!(id))
@@ -400,7 +394,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
{
const newComments = await apiGet<TheatreComment[]> (
`/theatres/${ id }/comments`,
{ params: { no_gt: lastCommentNoRef.current, limit: '20' } })
{ locale, params: { no_gt: lastCommentNoRef.current, limit: '20' } })
if (!(cancelled) && newComments.length > 0)
{
@@ -433,7 +427,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
}
const watchingRequestedAt = performance.now ()
const nextInfo = await apiPut<TheatreInfo> (`/theatres/${ id }/watching`)
const nextInfo = await apiPut<TheatreInfo> (`/theatres/${ id }/watching`, { }, { locale })
if (!(cancelled) && watchingRequestedAt >= theatreInfoReceivedAtRef.current)
applyTheatreInfo (nextInfo)
}
@@ -454,7 +448,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
cancelled = true
clearInterval (interval)
}
}, [applyTheatreInfo, currentPostElapsedMs, id])
}, [applyTheatreInfo, currentPostElapsedMs, id, locale])
useEffect (() => {
if (!(id) || !(theatreInfo.hostFlg) || loadingRef.current || theatreInfo.postId != null)
@@ -487,7 +481,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
void (async () => {
try
{
const nextPost = await fetchPost (String (theatreInfo.postId))
const nextPost = await fetchPost (String (theatreInfo.postId), locale)
if (!(cancelled))
setPost (nextPost)
}
@@ -500,7 +494,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
return () => {
cancelled = true
}
}, [theatreInfo.postId])
}, [theatreInfo.postId, locale])
useEffect (() => {
void refreshProgrammes ()
@@ -570,9 +564,9 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const nextInfo =
theatreInfo.skipVote.voted
? await apiDelete<TheatreInfo> (
`/theatres/${ id }/skip_vote`, { params: { post_id: post.id } })
`/theatres/${ id }/skip_vote`, { locale, params: { post_id: post.id } })
: await apiPut<TheatreInfo> (
`/theatres/${ id }/skip_vote`, { post_id: post.id })
`/theatres/${ id }/skip_vote`, { post_id: post.id }, { locale })
applyTheatreInfo (nextInfo)
@@ -586,7 +580,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
catch (error)
{
if (isApiError (error) && error.response?.status === 409)
applyTheatreInfo (await apiPut<TheatreInfo> (`/theatres/${ id }/watching`))
applyTheatreInfo (await apiPut<TheatreInfo> (`/theatres/${ id }/watching`, { }, { locale }))
console.error (error)
}
@@ -600,7 +594,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const handleDelete = async (commentNo: number) => {
try
{
await apiDelete (`/theatres/${ id }/comments/${ commentNo }`)
await apiDelete (`/theatres/${ id }/comments/${ commentNo }`, { locale })
setComments (prev => {
const rtn = [...prev]
const idx = rtn.findIndex (x => x.no === commentNo)
@@ -625,7 +619,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
{
setSending (true)
clearValidationErrors ()
await apiPost (`/theatres/${ id }/comments`, { content })
await apiPost (`/theatres/${ id }/comments`, { content }, { locale })
setContent ('')
commentsRef.current?.scrollTo ({
top: 0,
@@ -642,7 +636,9 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
}
const skipVote = theatreInfo.skipVote
const theatreTitle = theatre?.name ? `上映会場『${ theatre.name }』` : '上映会場'
const theatreTitle = theatre?.name
? message ('namedTitle', { name: theatre.name })
: message ('title')
const postTags = post?.tags ?? []
const programmesAsc = useMemo (
() => [...programmes].sort (
@@ -691,7 +687,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const tagPanel = (
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="font-bold">タグ</h2>
<h2 className="font-bold">{label ('navigation:tags')}</h2>
{layoutMode === 'tagsBottom' && (
<div className="hidden gap-2 md:flex">
{(Object.keys (TAG_FLOW_LABELS) as TagFlow[]).map (flow => (
@@ -701,23 +697,24 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
size="sm"
variant={tagFlow === flow ? 'default' : 'outline'}
onClick={() => changeTagFlow (flow)}>
{TAG_FLOW_LABELS[flow]}
{label (TAG_FLOW_LABELS[flow])}
</Button>))}
</div>)}
</div>
{postTags.length === 0
? <div className="text-sm text-zinc-500">タグはありません。</div>
? <div className="text-sm text-zinc-500">{label ('noTags')}</div>
: <TagList tags={postTags} flow={layoutMode === 'tagsBottom' ? tagFlow : 'vertical'}/>}
</section>)
const commentsPanel = (
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<h2 className="mb-3 font-bold">コメント</h2>
<h2 className="mb-3 font-bold">{label ('comments')}</h2>
<form onSubmit={handleCommentSubmit}>
<input
className={inputClass ((fieldErrors.content ?? []).length > 0)}
type="text"
placeholder="ここにコメントを入力"
placeholder={message ('commentPlaceholder').text}
lang={message ('commentPlaceholder').language}
value={content}
onChange={e => setContent (e.target.value)}
disabled={sending}/>
@@ -741,18 +738,21 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
className="absolute left-1 top-1 hidden rounded px-1 text-red-600
hover:bg-red-100 group-hover:inline-block dark:text-red-300
dark:hover:bg-red-950"
aria-label="コメントを削除"
aria-label={message ('deleteComment').text}
lang={message ('deleteComment').language}
onClick={async e => {
e.stopPropagation ()
if (!(await dialogue.confirm ({
title: 'このコメントを削除しますか?',
title: label ('confirmDelete'),
description: (
<div className="my-3 w-120 rounded border border-black p-2
dark:border-white">
{commentBox (comment, commentProgramme)}
</div>),
confirmText: '削除',
confirmText: label ('delete'),
cancelText: label ('shared:actions.cancel'),
closeLabel: label ('settings:actions.close'),
variant: 'danger' })))
return
@@ -768,23 +768,24 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const participantsPanel = (
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<h2 className="mb-3 font-bold">参加者</h2>
<h2 className="mb-3 font-bold">{label ('participants')}</h2>
<div className="space-y-1">
{theatreInfo.watchingUsers.map (watchingUser => (
<div key={watchingUser.id} className="flex justify-between gap-2 text-sm">
<span>{userName (watchingUser)}</span>
{watchingUser.id === user?.id && <span className="text-zinc-500">お前</span>}
{watchingUser.id === user?.id && (
<span className="text-zinc-500">{label ('navigation:currentUser')}</span>)}
</div>))}
</div>
</section>)
const historyPanel = (
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<h2 className="mb-3 font-bold">再生履歴</h2>
<h2 className="mb-3 font-bold">{label ('history')}</h2>
<div className="rounded border border-zinc-300 dark:border-zinc-800 max-h-72
overflow-y-auto">
{programmes.length === 0
? <div className="text-sm text-zinc-500">まだ履歴はありません。</div>
? <div className="text-sm text-zinc-500">{label ('emptyHistory')}</div>
: (
programmes.map (programme => (
<div
@@ -797,7 +798,9 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
{programme.post.title || programme.post.url}
</PrefetchLink>
<div className="text-xs text-zinc-500">
{dateString (programme.createdAt)}
<LocalisedText language={locale}>
{localisedDateString (programme.createdAt, locale)}
</LocalisedText>
</div>
</div>)))}
</div>
@@ -806,18 +809,18 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const weightsPanel = (
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="font-bold">抽選重み</h2>
<h2 className="font-bold">{label ('weights')}</h2>
<Button type="button" variant="outline" size="sm" onClick={() => void refreshWeights ()}>
更新
{label ('shared:actions.update')}
</Button>
</div>
<div className="mx-4 grid gap-16 xl:grid-cols-3">
<div>
<h3 className="mb-2 text-sm font-bold">出にくいタグ</h3>
<h3 className="mb-2 text-sm font-bold">{label ('unlikelyTags')}</h3>
<div className="space-y-1 text-sm">
{weights.tagPenalties.length === 0
? <div className="text-zinc-500">まだ減点はありません。</div>
? <div className="text-zinc-500">{label ('emptyPenalties')}</div>
: (
weights.tagPenalties.slice (0, 12).map (row => (
<div
@@ -833,12 +836,12 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
</div>
<div>
<h3 className="mb-2 text-sm font-bold">出にくい候補</h3>
<h3 className="mb-2 text-sm font-bold">{label ('unlikelyPosts')}</h3>
<WeightRows rows={weights.lightestPosts}/>
</div>
<div>
<h3 className="mb-2 text-sm font-bold">出やすい候補</h3>
<h3 className="mb-2 text-sm font-bold">{label ('likelyPosts')}</h3>
<WeightRows rows={weights.heaviestPosts}/>
</div>
</div>
@@ -850,10 +853,9 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
transition={{ layout: layoutTransition }}
className="min-h-0 flex-1 overflow-y-auto bg-zinc-50 text-zinc-950
md:overflow-hidden dark:bg-zinc-950 dark:text-zinc-50">
<Helmet>
<PageHead title={theatreTitle}>
<meta name="robots" content="noindex"/>
{theatre && <title>{`${ theatreTitle } | ${ SITE_TITLE }`}</title>}
</Helmet>
</PageHead>
<div
ref={sidebarContainerRef}
@@ -880,9 +882,13 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
<div className="flex flex-wrap items-center justify-between gap-3
border-zinc-200 px-4 py-3 dark:border-zinc-800">
<div>
<h1 className="text-lg font-bold">{theatreTitle}</h1>
<h1 className="text-lg font-bold">
<LocalisedText language={theatreTitle.language}>
{theatreTitle.text}
</LocalisedText>
</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
同接 {theatreInfo.watchingUsers.length} 人
{label ('watching', { count: theatreInfo.watchingUsers.length })}
</p>
</div>
@@ -895,7 +901,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
size="sm"
variant={layoutMode === mode ? 'default' : 'outline'}
onClick={() => changeLayoutMode (mode)}>
{LAYOUT_LABELS[mode]}
{label (LAYOUT_LABELS[mode])}
</Button>))}
</div>
@@ -905,7 +911,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
variant={skipVote.voted ? 'secondary' : 'destructive'}
disabled={loading || !(post)}
onClick={handleSkipVote}>
{skipVote.voted ? 'スキップ取消' : 'スキップ'}
{skipVote.voted ? label ('cancelSkip') : label ('shared:actions.skip')}
{` ${ skipVote.votesCount } / ${ skipVote.requiredCount }`}
</Button>
@@ -922,14 +928,14 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
onPlaybackChange={syncPlaybackTime}
onError={handlePlaybackError}/>) : (
<div className="grid min-h-72 place-items-center text-zinc-400">
{loading ? '次の投稿を選んでゐます……' : '上映待機中'}
{label (loading ? 'selecting' : 'waiting')}
</div>)}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0">
<div className="text-xs font-bold text-zinc-500 dark:text-zinc-400">
再生中
{label ('playing')}
</div>
{post ? (
<PrefetchLink
@@ -937,7 +943,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
className="font-bold hover:underline">
{post.title || post.url}
</PrefetchLink>) : (
<span className="text-zinc-500">未選択</span>)}
<span className="text-zinc-500">{label ('unselected')}</span>)}
</div>
{(post && canEditContent (user)) && (
@@ -947,7 +953,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
variant="outline"
disabled={!(post)}
onClick={() => post && setEditingPost (ep => ep ? null : post)}>
{editingPost ? '閉じる' : '編輯'}
{editingPost ? label ('close') : label ('shared:actions.edit')}
</Button>)}
</div>
</section>
@@ -956,14 +962,14 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
<section className="rounded border border-amber-300 bg-amber-50 mx-4 p-4
dark:border-amber-800 dark:bg-amber-950/30">
<div className="mb-3">
<h2 className="font-bold">編輯中の投稿</h2>
<h2 className="font-bold">{label ('editingPost')}</h2>
<p className="text-sm text-amber-900 dark:text-amber-100">
<PrefetchLink
to={`/posts/${ editingPost.id }`}
className="mx-1 font-bold underline">
{editingPost.title || editingPost.url}
</PrefetchLink>
を編輯中……
{label ('editingSuffix')}
</p>
</div>
@@ -1026,27 +1032,30 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
}
const WeightRows: FC<{ rows: TheatrePostSelectionWeights['lightestPosts'] }> = ({ rows }) => (
<div className="space-y-2 text-sm">
{rows.length === 0
? <div className="text-zinc-500">候補はありません。</div>
: (
rows.slice (0, 8).map (row => (
<div
key={row.post.id}
className="border-zinc-100 pt-2 first:border-t-0 first:pt-0
dark:border-zinc-800">
<PrefetchLink
to={`/posts/${ row.post.id }`}
className="line-clamp-1 font-bold hover:underline">
{row.post.title || row.post.url}
</PrefetchLink>
<div className="flex justify-between gap-2 text-xs text-zinc-500">
<span>penalty {row.penalty}</span>
<span>weight {row.weight.toFixed (3)}</span>
</div>
</div>)))}
</div>)
const WeightRows: FC<{ rows: TheatrePostSelectionWeights['lightestPosts'] }> = ({ rows }) => {
const { label } = useUITranslation ('theatres')
return (
<div className="space-y-2 text-sm">
{rows.length === 0
? <div className="text-zinc-500">{label ('emptyPosts')}</div>
: (
rows.slice (0, 8).map (row => (
<div
key={row.post.id}
className="border-zinc-100 pt-2 first:border-t-0 first:pt-0
dark:border-zinc-800">
<PrefetchLink
to={`/posts/${ row.post.id }`}
className="line-clamp-1 font-bold hover:underline">
{row.post.title || row.post.url}
</PrefetchLink>
<div className="flex flex-wrap justify-between gap-2 text-xs text-zinc-500">
<span>{label ('penalty', { value: row.penalty })}</span>
<span>{label ('weight', { value: row.weight.toFixed (3) })}</span>
</div>
</div>)))}
</div>)
}
export default TheatreDetailPage
+3 -1
ファイルの表示
@@ -1,6 +1,7 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { textContentOf } from '@/test/text'
import SettingPage from '@/pages/users/SettingPage'
import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
@@ -68,7 +69,8 @@ describe ('SettingPage', () => {
const formData = api.apiPut.mock.calls[0]?.[1] as FormData
expect (formData.get ('name')).toBe ('new')
expect (setUser).toHaveBeenCalled ()
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '表示名を更新しました.' })
expect (toastApi.toast).toHaveBeenCalledTimes (1)
expect (textContentOf (toastApi.toast.mock.calls[0][0].title)).toBe ('表示名を更新しました.')
})
it ('shows validation errors returned for the name field', async () => {
+197 -170
ファイルの表示
@@ -1,23 +1,25 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useBeforeUnload, useLocation, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useBeforeUnload, useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import TagLink from '@/components/TagLink'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { useDialogue } from '@/lib/dialogues/useDialogue'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import BehaviourSettingsSection from '@/components/users/BehaviourSettingsSection'
import InheritDialogue from '@/components/users/InheritDialogue'
import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection'
import UserCodeDialogue from '@/components/users/UserCodeDialogue'
import { CATEGORY_NAMES, CATEGORIES } from '@/consts'
import { SITE_TITLE } from '@/config'
import { CATEGORIES } from '@/consts'
import PageHead from '@/components/common/PageHead'
import { apiPut } from '@/lib/api'
import { i18n } from '@/lib/i18n'
import {
applyClientAppearance,
applyThemeAppearanceState,
@@ -40,8 +42,11 @@ import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import { useKeyboardShortcutSettings, useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { cn, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import { useUITranslation } from '@/lib/useUITranslation'
import { useHydrated } from '@/lib/useHydrated'
import { sessionKeys } from '@/lib/queryKeys'
import type { CSSProperties, Dispatch, FC, KeyboardEvent, SetStateAction } from 'react'
import type { CSSProperties, Dispatch, FC, KeyboardEvent, ReactNode, SetStateAction } from 'react'
import type { Category, Tag, User } from '@/types'
import type {
@@ -84,7 +89,6 @@ type ThemeModeItem = {
type ThemeSelectionItem = {
id: UserThemeSlotSelection
label: string
baseTheme: BuiltinThemeId
slotNo: UserThemeSlotNo }
@@ -128,7 +132,7 @@ type ThemeDraftState = {
type ThemeSegmentedOption<T extends string | number> = {
value: T
label: string }
label: ReactNode }
type ThemeSegmentedControlProps<T extends string | number> = {
value: T
@@ -136,15 +140,15 @@ type ThemeSegmentedControlProps<T extends string | number> = {
onChange: (value: T) => void }
const tabs: TabSpec[] = [
{ id: 'account', label: 'アカウント' },
{ id: 'behavior', label: '動作' },
{ id: 'theme', label: 'テーマ' },
{ id: 'keyboard', label: 'キーボード' } ]
{ id: 'account', label: 'tabs.account' },
{ id: 'behavior', label: 'tabs.behavior' },
{ id: 'theme', label: 'tabs.theme' },
{ id: 'keyboard', label: 'tabs.keyboard' }]
const themeModeSelections: ThemeModeItem[] = [
{ id: 'system', label: 'システム設定に従う' },
{ id: 'light', label: 'ライト' },
{ id: 'dark', label: 'ダーク' } ]
{ id: 'system', label: 'theme.modes.system' },
{ id: 'light', label: 'theme.modes.light' },
{ id: 'dark', label: 'theme.modes.dark' }]
const themeSlotNoSelections: ThemeSegmentedOption<UserThemeSlotNo>[] = [
{ value: 1, label: '1' },
@@ -155,20 +159,19 @@ const sectionClassName =
'space-y-4 rounded-xl p-4'
const themeSelections: ThemeSelectionItem[] = [
{ id: 'user:light:1', label: 'ライト 1', baseTheme: 'light', slotNo: 1 },
{ id: 'user:light:2', label: 'ライト 2', baseTheme: 'light', slotNo: 2 },
{ id: 'user:light:3', label: 'ライト 3', baseTheme: 'light', slotNo: 3 },
{ id: 'user:dark:1', label: 'ダーク 1', baseTheme: 'dark', slotNo: 1 },
{ id: 'user:dark:2', label: 'ダーク 2', baseTheme: 'dark', slotNo: 2 },
{ id: 'user:dark:3', label: 'ダーク 3', baseTheme: 'dark', slotNo: 3 },
]
{ id: 'user:light:1', baseTheme: 'light', slotNo: 1 },
{ id: 'user:light:2', baseTheme: 'light', slotNo: 2 },
{ id: 'user:light:3', baseTheme: 'light', slotNo: 3 },
{ id: 'user:dark:1', baseTheme: 'dark', slotNo: 1 },
{ id: 'user:dark:2', baseTheme: 'dark', slotNo: 2 },
{ id: 'user:dark:3', baseTheme: 'dark', slotNo: 3 }]
const themeTokenGroups: ThemeTokenGroupSpec[] = [
{ title: '基本',
{ title: 'theme.basic',
fields: [
{ key: 'background', label: '背景' },
{ key: 'foreground', label: '文字' },
{ key: 'link', label: 'リンク' }] }]
{ key: 'background', label: 'theme.tokens.background' },
{ key: 'foreground', label: 'theme.tokens.foreground' },
{ key: 'link', label: 'theme.tokens.link' }] }]
const ThemeSegmentedControl = <T extends string | number,> (
@@ -219,12 +222,12 @@ const previewTag = (
children: [] })
const previewTags: Record<Category, Tag> = {
deerjikist: previewTag (1, 'にじらー', 'deerjikist', 12, true, { hasDeerjikists: true }),
meme: previewTag (2, 'ミーム', 'meme', 34, true),
character: previewTag (3, 'キャラクター', 'character', 56, false),
general: previewTag (4, '一般', 'general', 78, true),
material: previewTag (5, '素材', 'material', 90, false),
meta: previewTag (6, 'メタ', 'meta', 21, true),
deerjikist: previewTag (1, '', 'deerjikist', 12, true, { hasDeerjikists: true }),
meme: previewTag (2, '', 'meme', 34, true),
character: previewTag (3, '', 'character', 56, false),
general: previewTag (4, '', 'general', 78, true),
material: previewTag (5, '', 'material', 90, false),
meta: previewTag (6, '', 'meta', 21, true),
nico: previewTag (7, 'nico:sm9', 'nico', 43, false) }
const parseTab = (search: string): SettingsTab => {
@@ -243,15 +246,23 @@ const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties =>
[`--tag-colour-${ category }`, tagColours[category]],
[`--tag-colour-${ category }-hover`, tagColours[category]]])) as CSSProperties
const themeLabelBySelection = (selection: UserThemeSlotSelection): string =>
themeSelections.find (item => item.id === selection)?.label ?? 'ライト 1'
const themeLabelBySelection = (
selection: UserThemeSlotSelection,
locale: string,
): string => {
const item = themeSelections.find (entry => entry.id === selection) ?? themeSelections[0]
return i18n.t ('settings:theme.slot', {
lng: locale,
mode: i18n.t (`settings:theme.modes.${item.baseTheme}`, { lng: locale }),
number: item.slotNo })
}
const ensureCompleteThemeSlots = (
themeSlots: UserThemeSlotMap,
): UserThemeSlotMap =>
USER_THEME_SLOT_SELECTIONS.reduce<UserThemeSlotMap> (
(complete, selection) => {
const [_, baseTheme, slotNo] = selection.split (':')
const [, baseTheme, slotNo] = selection.split (':')
complete[selection] =
themeSlots[selection]
?? getDefaultUserThemeSlot (
@@ -267,7 +278,7 @@ const comparableThemeTokensForSelection = (
themeSlots: UserThemeSlotMap,
selection: UserThemeSlotSelection,
): ThemeTokens => {
const [_, baseTheme, slotNo] = selection.split (':')
const [, baseTheme, slotNo] = selection.split (':')
const fallback =
getDefaultUserThemeSlot (
baseTheme as BuiltinThemeId,
@@ -416,91 +427,79 @@ const hexToThemeToken = (
isTopNavThemeTokenKey (key) ? value : hexToHslToken (value)
const AccountSection: FC<AccountSectionProps> = (
{ name,
onInheritOpen,
onNameChange,
onUserCodeOpen,
onUserSubmit,
user,
userBaseErrors,
userFieldErrors },
) => (
<section className={sectionClassName}>
<h2 className="text-xl font-bold">アカウント</h2>
<FieldError messages={userBaseErrors}/>
const AccountSection: FC<AccountSectionProps> = props => {
const { name, onInheritOpen, onNameChange, onUserCodeOpen,
onUserSubmit, user, userBaseErrors, userFieldErrors } = props
const { message, label } = useUITranslation ('settings')
return (
<section className={sectionClassName}>
<h2 className="text-xl font-bold">{label ('tabs.account')}</h2>
<FieldError messages={userBaseErrors}/>
<FormField label="表示名" messages={userFieldErrors.name}>
{({ describedBy, invalid }) => (
<>
<input
type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={name}
placeholder="名もなきニジラー"
onChange={ev => onNameChange (ev.target.value)}/>
{!(user.name) && (
<p className="mt-1 text-sm text-red-500">
名前が未設定のアカウントは 30 日間アクセスしないと削除されます!!!!
</p>)}
</>)}
</FormField>
<FormField label={label ('account.displayName')} messages={userFieldErrors.name}>
{({ describedBy, invalid }) => (
<>
<input
type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={name}
placeholder={message ('shared:labels.anonymous').text}
lang={message ('shared:labels.anonymous').language}
onChange={ev => onNameChange (ev.target.value)}/>
{!(user.name) && (
<p className="mt-1 text-sm text-red-500">
{label ('account.unnamedWarning')}
</p>)}
</>)}
</FormField>
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={onUserSubmit}>
表示名を更新
</Button>
</div>
<div className="space-y-2">
<Label>引継ぎ</Label>
<div className="flex flex-wrap gap-2">
<Button
type="button"
onClick={onUserCodeOpen}
className="bg-gray-600 text-white"
disabled={!(user)}>
引継ぎコードを表示
</Button>
<Button
type="button"
onClick={onInheritOpen}
className="bg-red-600 text-white"
disabled={!(user)}>
ほかのブラウザから引継ぐ
<Button type="button" onClick={onUserSubmit}>
{label ('account.updateName')}
</Button>
</div>
</div>
</section>)
const ThemeSection: FC<ThemeSectionProps> = (
{ draftThemeMode,
draftActiveLightThemeSlotNo,
draftActiveDarkThemeSlotNo,
currentDisplayedThemeSlot,
selectedTheme,
hasUnsavedChanges,
settingsBaseErrors,
settingsFieldErrors,
onSelectThemeMode,
onSelectLightThemeSlotNo,
onSelectDarkThemeSlotNo,
onThemeTokenChange,
onTagColourChange,
onResetThemeSlot,
onThemeSubmit,
onThemeDiscard },
) => {
<div className="space-y-2">
<Label>{label ('account.transfer')}</Label>
<div className="flex flex-wrap gap-2">
<Button
type="button"
onClick={onUserCodeOpen}
className="bg-gray-600 text-white"
disabled={!(user)}>
{label ('account.showCode')}
</Button>
<Button
type="button"
onClick={onInheritOpen}
className="bg-red-600 text-white"
disabled={!(user)}>
{label ('account.inherit')}
</Button>
</div>
</div>
</section>)
}
const ThemeSection: FC<ThemeSectionProps> = props => {
const { draftThemeMode, draftActiveLightThemeSlotNo, draftActiveDarkThemeSlotNo,
currentDisplayedThemeSlot, selectedTheme, hasUnsavedChanges,
settingsBaseErrors, settingsFieldErrors, onSelectThemeMode,
onSelectLightThemeSlotNo, onSelectDarkThemeSlotNo, onThemeTokenChange,
onTagColourChange, onResetThemeSlot, onThemeSubmit, onThemeDiscard } = props
const { message, label } = useUITranslation ('settings')
const locale = useLocale ()
return (
<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.theme')}</h2>
{hasUnsavedChanges && (
<p className="text-sm text-amber-700 dark:text-amber-300">
未保存の変更があります
{label ('unsaved.title')}
</p>)}
</div>
@@ -509,7 +508,7 @@ const ThemeSection: FC<ThemeSectionProps> = (
type="button"
variant="outline"
onClick={onResetThemeSlot}>
既定に戻す
{label ('actions.reset')}
</Button>
<Button
@@ -517,13 +516,14 @@ const ThemeSection: FC<ThemeSectionProps> = (
variant="destructive"
disabled={!(hasUnsavedChanges)}
onClick={onThemeDiscard}>
変更を破棄
{label ('actions.discard')}
</Button>
<Button
type="button"
disabled={!(hasUnsavedChanges)}
onClick={onThemeSubmit}>
{`${ themeLabelBySelection (currentDisplayedThemeSlot) } に保存`}
{label ('theme.saveToSlot', {
slot: themeLabelBySelection (currentDisplayedThemeSlot, locale) })}
</Button>
</div>
</div>
@@ -534,42 +534,48 @@ const ThemeSection: FC<ThemeSectionProps> = (
<div className="space-y-3 rounded-xl p-4">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="shrink-0">
<Label>表示モード</Label>
<Label>{label ('theme.displayMode')}</Label>
</div>
<ThemeSegmentedControl
value={draftThemeMode}
options={themeModeSelections.map (selection => ({
value: selection.id,
label: selection.label }))}
label: label (selection.label) }))}
onChange={value => onSelectThemeMode (value as ClientThemeMode)}/>
</div>
<div className="space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>ライト時</Label>
<Label>{label ('theme.whenLight')}</Label>
<select
className={cn (inputClass (false), 'w-full')}
value={draftActiveLightThemeSlotNo}
aria-label={message ('theme.whenLight').text}
lang={message ('theme.whenLight').language}
onChange={ev =>
onSelectLightThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
{themeSlotNoSelections.map (selection => (
<option key={selection.value} value={selection.value}>
{`ライト ${ selection.value }`}
{message ('theme.slot', { mode: message ('theme.modes.light').text,
number: selection.value }).text}
</option>))}
</select>
</div>
<div className="space-y-2">
<Label>ダーク時</Label>
<Label>{label ('theme.whenDark')}</Label>
<select
className={cn (inputClass (false), 'w-full')}
value={draftActiveDarkThemeSlotNo}
aria-label={message ('theme.whenDark').text}
lang={message ('theme.whenDark').language}
onChange={ev =>
onSelectDarkThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
{themeSlotNoSelections.map (selection => (
<option key={selection.value} value={selection.value}>
{`ダーク ${ selection.value }`}
{message ('theme.slot', { mode: message ('theme.modes.dark').text,
number: selection.value }).text}
</option>))}
</select>
</div>
@@ -582,17 +588,19 @@ const ThemeSection: FC<ThemeSectionProps> = (
<div
key={group.title}
className="space-y-3 rounded-xl p-4">
<h3 className="text-sm font-semibold">{group.title}</h3>
<h3 className="text-sm font-semibold">{label (group.title)}</h3>
<div className="space-y-1">
{group.fields.map (field => (
<div
key={field.key}
className="flex items-center justify-between gap-4 border-b border-border/60 py-2
last:border-b-0">
<p className="text-sm font-medium">{field.label}</p>
<p className="text-sm font-medium">{label (field.label)}</p>
<div className="flex items-center gap-3">
<input
type="color"
aria-label={message (field.label).text}
lang={message (field.label).language}
value={themeTokenToHex (field.key, selectedTheme.tokens[field.key])}
onChange={ev =>
onThemeTokenChange (field.key, hexToThemeToken (field.key, ev.target.value))}
@@ -607,7 +615,7 @@ const ThemeSection: FC<ThemeSectionProps> = (
<div className="space-y-3 rounded-xl bg-background/70 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="font-medium">タグ</h3>
<h3 className="font-medium">{label ('navigation:tags')}</h3>
</div>
</div>
@@ -618,10 +626,14 @@ const ThemeSection: FC<ThemeSectionProps> = (
className="flex items-center justify-between gap-4 border-b border-border/60 py-2
last:border-b-0">
<div className="flex min-w-0 items-center gap-3">
<p className="text-sm font-medium">{CATEGORY_NAMES[category]}</p>
<p className="text-sm font-medium">
{label (`common:categories.${category}`)}
</p>
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
<TagLink
tag={previewTags[category]}
lang={message (`theme.previews.${category}`).language}
tag={{ ...previewTags[category],
name: message (`theme.previews.${category}`).text }}
linkFlg={false}
truncateOnMobile
withWiki={false}/>
@@ -631,6 +643,8 @@ const ThemeSection: FC<ThemeSectionProps> = (
<div className="flex items-center gap-3">
<input
type="color"
aria-label={message (`common:categories.${category}`).text}
lang={message (`common:categories.${category}`).language}
value={selectedTheme.tokens.tagColours[category]}
onChange={ev => onTagColourChange (category, ev.target.value)}
className="h-9 w-14 cursor-pointer rounded border border-border
@@ -646,31 +660,42 @@ const ThemeSection: FC<ThemeSectionProps> = (
const SettingPage: FC<Props> = ({ user, setUser }) => {
const hydrated = useHydrated ()
const { data: initialSettings } = useQuery ({
queryKey: sessionKeys.settings (user?.id ?? 0),
queryFn: fetchUserSettings, enabled: user != null })
const { data: initialThemeSlots } = useQuery ({
queryKey: sessionKeys.themeSlots (user?.id ?? 0),
queryFn: fetchUserThemeSlots, enabled: user != null })
const { message, label } = useUITranslation ('settings')
const locale = useLocale ()
const location = useLocation ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const dialogue = useDialogue ()
const { registerUnsavedChangesSource } = useUnsavedChangesGuard ()
const [inheritVsbl, setInheritVsbl] = useState (false)
const [loading, setLoading] = useState (true)
const [name, setName] = useState ('')
const [loading, setLoading] = useState (initialSettings == null)
const [name, setName] = useState (user?.name ?? '')
const [settingsError, setSettingsError] = useState<string | null> (null)
const [savedThemeMode, setSavedThemeMode] =
useState<ClientThemeMode> (getClientThemeMode ())
useState<ClientThemeMode> (initialSettings?.theme ?? 'system')
const [draftThemeMode, setDraftThemeMode] =
useState<ClientThemeMode> (getClientThemeMode ())
useState<ClientThemeMode> (initialSettings?.theme ?? 'system')
const [savedActiveLightThemeSlotNo, setSavedActiveLightThemeSlotNo] =
useState<UserThemeSlotNo> (getClientActiveLightThemeSlotNo ())
useState<UserThemeSlotNo> (1)
const [draftActiveLightThemeSlotNo, setDraftActiveLightThemeSlotNo] =
useState<UserThemeSlotNo> (getClientActiveLightThemeSlotNo ())
useState<UserThemeSlotNo> (1)
const [savedActiveDarkThemeSlotNo, setSavedActiveDarkThemeSlotNo] =
useState<UserThemeSlotNo> (getClientActiveDarkThemeSlotNo ())
useState<UserThemeSlotNo> (1)
const [draftActiveDarkThemeSlotNo, setDraftActiveDarkThemeSlotNo] =
useState<UserThemeSlotNo> (getClientActiveDarkThemeSlotNo ())
useState<UserThemeSlotNo> (1)
const [savedThemeSlots, setSavedThemeSlots] =
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots (
normaliseUserThemeSlots (initialThemeSlots ?? [])))
const [draftThemeSlots, setDraftThemeSlots] =
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots (
normaliseUserThemeSlots (initialThemeSlots ?? [])))
const [dirtyStates, setDirtyStates] =
useState<Partial<Record<SettingsTab, SettingsTabDirtyState>>> ({ })
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
@@ -688,10 +713,10 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const activeTab = useMemo<SettingsTab> (() => parseTab (location.search), [location.search])
const displayedThemeSelection = useMemo (
() => resolveDisplayedTheme ({
themeMode: draftThemeMode,
themeMode: (!hydrated && draftThemeMode === 'system') ? 'light' : draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots),
[draftActiveDarkThemeSlotNo,
[hydrated, draftActiveDarkThemeSlotNo,
draftActiveLightThemeSlotNo,
draftThemeMode,
draftThemeSlots])
@@ -707,12 +732,12 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
?? getDefaultUserThemeSlot (currentSelection.baseTheme, currentSelection.slotNo)
return {
id: currentSelection.baseTheme,
name: themeLabelBySelection (currentSelection.id),
name: themeLabelBySelection (currentSelection.id, locale),
builtin: false,
baseTheme: currentSelection.baseTheme,
tokens: themeSlot.tokens }
},
[displayedThemeSelection.selection, draftThemeSlots])
[displayedThemeSelection.selection, draftThemeSlots, locale])
const hasUnsavedThemeChanges = useMemo (
() =>
serialiseThemeTokens (draftThemeSlots, displayedThemeSelection.selection)
@@ -752,12 +777,13 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
}
const confirmDiscardChanges = async (
description: string,
cancelText: string,
confirmText: string,
description: ReactNode,
cancelText: ReactNode,
confirmText: ReactNode,
): Promise<boolean> =>
dialogue.confirm ({
title: '未保存の変更があります',
title: label ('unsaved.title'),
closeLabel: label ('actions.close'),
description,
confirmText,
cancelText,
@@ -811,7 +837,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const discardThemeChanges = () => {
const selection = displayedThemeSelection.selection
const [_, baseTheme, slotNo] = selection.split (':')
const [, baseTheme, slotNo] = selection.split (':')
const nextThemeSlots = {
...draftThemeSlots,
[selection]: savedThemeSlots[selection]
@@ -865,9 +891,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
}
const confirmed = await confirmDiscardChanges (
'このタブを離れると、保存していない変更は失われます。',
'このタブに残る',
'変更を破棄して移動')
label ('unsaved.leaveTab'),
label ('unsaved.stayTab'),
label ('unsaved.discardAndMove'))
if (!(confirmed))
return
@@ -877,9 +903,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const requestThemeStateChange = async (
buildNextState: (baseState: ThemeDraftState) => ThemeDraftState,
description = 'このまま切り替えると、保存していない変更は失われます。',
cancelText = 'このまま編集する',
confirmText = '変更を破棄して切り替える',
description = label ('unsaved.switchTheme'),
cancelText = label ('unsaved.keepEditing'),
confirmText = label ('unsaved.discardAndSwitch'),
): Promise<void> => {
const baseState = currentThemeDraftState ()
let themeSlots = baseState.themeSlots
@@ -894,7 +920,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
return
const selection = displayedThemeSelection.selection
const [_, baseTheme, slotNo] = selection.split (':')
const [, baseTheme, slotNo] = selection.split (':')
themeSlots = {
...baseState.themeSlots,
[selection]: savedThemeSlots[selection]
@@ -1032,12 +1058,12 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
{ headers: { 'Content-Type': 'multipart/form-data' } })
setUser (currentUser => ({ ...currentUser, ...data }))
setName (data.name ?? '')
toast ({ title: '表示名を更新しました.' })
toast ({ title: label ('account.nameSaved') })
}
catch (submitError)
{
applyUserValidationError (submitError)
toast ({ title: '表示名を更新できませんでした.' })
toast ({ title: label ('account.nameFailed') })
}
}
@@ -1068,12 +1094,12 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
setDraftThemeSlots (completeThemeSlots)
setCachedUserThemeSlots (completeThemeSlots)
applyClientAppearance (completeThemeSlots)
toast ({ title: 'テーマ設定を保存しました.' })
toast ({ title: label ('theme.saved') })
}
catch (submitError)
{
applySettingsValidationError (submitError)
toast ({ title: 'テーマ設定を保存できませんでした.' })
toast ({ title: label ('theme.failed') })
}
}
@@ -1132,7 +1158,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
if (cancelled)
return
setSettingsError ('設定の読込みに失敗しました.')
setSettingsError ('loadFailed')
}
finally
{
@@ -1222,8 +1248,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
draftActiveDarkThemeSlotNo,
currentDisplayedThemeSlot: displayedThemeSelection.selection,
selectedTheme,
displayedThemeLabel: themeLabelBySelection (displayedThemeSelection.selection),
systemThemeLabel: displayedThemeSelection.baseTheme === 'dark' ? 'ダーク' : 'ライト',
displayedThemeLabel: themeLabelBySelection (displayedThemeSelection.selection, locale),
systemThemeLabel: message (`theme.modes.${displayedThemeSelection.baseTheme}`).text,
hasUnsavedChanges: hasUnsavedThemeChanges,
settingsBaseErrors,
settingsFieldErrors,
@@ -1259,17 +1285,17 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
return (
<MainArea>
<Helmet>
<PageHead title={message ('title')}>
<meta name="robots" content="noindex"/>
<title>設定 | {SITE_TITLE}</title>
</Helmet>
</PageHead>
<div className="mx-auto max-w-xl space-y-4 p-4 md:hidden">
<PageTitle>設定</PageTitle>
<FieldError messages={settingsError ? [settingsError] : []}/>
<PageTitle>{label ('title')}</PageTitle>
<FieldError messages={settingsError ? [label (settingsError)] : []}/>
<div
role="tablist"
aria-label="設定区分"
aria-label={message ('tabList').text}
lang={message ('tabList').language}
aria-orientation="horizontal"
className="-mx-4 flex gap-2 overflow-x-auto px-4 pb-1">
{tabs.map (tab => (
@@ -1290,11 +1316,11 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
onClick={() => {
requestActiveTab (tab.id)
}}>
{tab.label}
{label (tab.label)}
{settingsTabErrors[tab.id] ? ' !' : ''}
</button>))}
</div>
{loading ? 'Loading...' : (
{loading ? label ('common:loading') : (
<div
id={tabPanelId (activeTab)}
role="tabpanel"
@@ -1321,7 +1347,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
<div className="grid w-full max-w-[58rem] grid-cols-[12rem_minmax(0,1fr)] gap-6">
<div
role="tablist"
aria-label="設定区分"
aria-label={message ('tabList').text}
lang={message ('tabList').language}
aria-orientation="vertical"
className="sticky top-4 flex h-fit flex-col gap-2">
{tabs.map (tab => (
@@ -1345,15 +1372,15 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
requestActiveTab (tab.id)
}}
onKeyDown={handleTabKeyDown}>
{tab.label}
{label (tab.label)}
{settingsTabErrors[tab.id] ? ' ⚠' : ''}
</button>))}
</div>
<div className="w-full max-w-xl justify-self-center space-y-4">
<PageTitle>設定</PageTitle>
<FieldError messages={settingsError ? [settingsError] : []}/>
{loading ? 'Loading...' : (
<PageTitle>{label ('title')}</PageTitle>
<FieldError messages={settingsError ? [label (settingsError)] : []}/>
{loading ? label ('common:loading') : (
<div
id={tabPanelId (activeTab)}
role="tabpanel"
+26 -18
ファイルの表示
@@ -24,7 +24,15 @@ const postsApi = vi.hoisted (() => ({
fetchPosts: vi.fn (),
}))
vi.mock ('@/lib/wiki', () => wikiApi)
vi.mock ('@/lib/wiki', () => ({
...wikiApi,
fetchWikiAutolinkCandidates: async () => [],
fetchWikiTag: (reference: { name?: string; wikiId?: string }, locale: string) =>
tagsApi.fetchTagByName (reference.name ?? reference.wikiId, locale),
fetchWikiPageByReference: (reference: string, ...args: unknown[]) =>
/^\d+$/.test (reference)
? wikiApi.fetchWikiPage (reference, ...args)
: wikiApi.fetchWikiPageByTitle (reference, ...args) }))
vi.mock ('@/lib/tags', () => tagsApi)
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/help', () => ({ getHelpTitleIndex: vi.fn () }))
@@ -63,15 +71,15 @@ describe ('WikiDetailPage', () => {
vi.mocked (getHelpTitleIndex).mockReturnValueOnce (pending)
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: '既存タグ', hasWiki: false }))
renderWithProviders (
<Routes><Route path="/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
{ route: `/wiki/${ encodeURIComponent ('既存タグ') }` })
<Routes><Route path="/ja/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
{ route: `/ja/wiki/${ encodeURIComponent ('既存タグ') }` })
await waitFor (() => expect (getHelpTitleIndex).toHaveBeenCalled ())
expect (screen.queryByText ('404')).not.toBeInTheDocument ()
expect (screen.queryByRole ('link', { name: '新規作成してください' }))
.not.toBeInTheDocument ()
await act (async () => finish ({ }))
expect (await screen.findByRole ('link', { name: '新規作成してください' }))
.toHaveAttribute ('href', `/wiki/new?title=${ encodeURIComponent ('既存タグ') }`)
.toHaveAttribute ('href', `/ja/wiki/new?title=${ encodeURIComponent ('既存タグ') }`)
})
it ('creates a tag description through the existing form and displays its content', async () => {
@@ -79,13 +87,13 @@ describe ('WikiDetailPage', () => {
resourceType: 'tag_description' })
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: page.title, hasWiki: false }))
vi.mocked (apiPost).mockResolvedValueOnce (page)
const path = `/wiki/${ encodeURIComponent (page.title) }`
const path = `/ja/wiki/${ encodeURIComponent (page.title) }`
const { container, queryClient } = renderWithProviders (
<>
<Location/>
<Routes>
<Route path="/wiki/new" element={<WikiNewPage user={buildUser ({ role: 'member' })}/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/ja/wiki/new" element={<WikiNewPage user={buildUser ({ role: 'member' })}/>}/>
<Route path="/ja/wiki/:title" element={<WikiDetailPage/>}/>
</Routes>
</>, { route: path })
fireEvent.click (await screen.findByRole ('link', { name: '新規作成してください' }))
@@ -108,12 +116,12 @@ describe ('WikiDetailPage', () => {
expect (form.get ('title')).toBe (page.title)
expect (form.get ('body')).toBe (page.body)
expect (apiPost).toHaveBeenCalledWith ('/wiki', form,
{ headers: { 'Content-Type': 'multipart/form-data' } })
{ locale: 'ja', headers: { 'Content-Type': 'multipart/form-data' } })
expect (screen.queryByText ('404')).not.toBeInTheDocument ()
expect (queryClient.getQueryData (wikiKeys.show (page.title, { }))).toEqual (page)
await waitFor (() => expect (queryClient.getQueryData (wikiKeys.index ({ })))
.toEqual ([page]))
await waitFor (() => expect (queryClient.getQueryData (tagsKeys.show (page.title)))
expect (queryClient.getQueryState (wikiKeys.index ({ }))?.isInvalidated).toBe (true)
await waitFor (() => expect (queryClient.getQueryData (
wikiKeys.tag ({ name: page.title }, 'ja')))
.toMatchObject ({ hasWiki: true }))
expect (getHelpTitleIndex).toHaveBeenCalledTimes (1)
})
@@ -121,7 +129,7 @@ describe ('WikiDetailPage', () => {
it ('keeps failed saves on the form without changing cached Wiki or tag state', async () => {
const title = '既存タグ'
const tag = buildTag ({ name: title, hasWiki: false })
const path = `/wiki/new?title=${ encodeURIComponent (title) }`
const path = `/ja/wiki/new?title=${ encodeURIComponent (title) }`
vi.mocked (apiPost).mockRejectedValueOnce (new Error ('save failed'))
const { queryClient } = renderWithProviders (
<><Location/><WikiNewPage user={buildUser ({ role: 'member' })}/></>, { route: path })
@@ -141,8 +149,8 @@ describe ('WikiDetailPage', () => {
it ('shows NotFound when neither a tag nor a Help title exists', async () => {
renderWithProviders (
<Routes><Route path="/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
{ route: '/wiki/missing' })
<Routes><Route path="/ja/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
{ route: '/ja/wiki/missing' })
expect (await screen.findByText ('404')).toBeInTheDocument ()
expect (screen.queryByRole ('link', { name: '新規作成してください' }))
.not.toBeInTheDocument ()
@@ -153,8 +161,8 @@ describe ('WikiDetailPage', () => {
wikiApi.fetchWikiPageByTitle.mockRejectedValue (new Error (message))
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: '既存タグ' }))
renderWithProviders (
<Routes><Route path="/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
{ route: `/wiki/${ encodeURIComponent ('既存タグ') }` })
<Routes><Route path="/ja/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
{ route: `/ja/wiki/${ encodeURIComponent ('既存タグ') }` })
expect (await screen.findByRole ('alert')).toHaveTextContent (message)
expect (getHelpTitleIndex).not.toHaveBeenCalled ()
expect (screen.queryByRole ('link', { name: '新規作成してください' }))
@@ -174,9 +182,9 @@ describe ('WikiDetailPage', () => {
renderWithProviders (
<Routes>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/ja/wiki/:title" element={<WikiDetailPage/>}/>
</Routes>,
{ route: '/wiki/%E6%97%A7%E3%82%BF%E3%82%B0' },
{ route: '/ja/wiki/%E6%97%A7%E3%82%BF%E3%82%B0' },
)
const marker = await screen.findByText ('(廃止)')
+56 -60
ファイルの表示
@@ -1,34 +1,38 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { useLocation, useParams } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import PostList from '@/components/PostList'
import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink'
import WikiBody from '@/components/WikiBody'
import TabGroup, { Tab } from '@/components/common/TabGroup'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { getHelpTitleIndex } from '@/lib/help'
import { localeLanguage } from '@/lib/locales'
import { fetchPosts } from '@/lib/posts'
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTagByName } from '@/lib/tags'
import { fetchWikiPage, fetchWikiPageByTitle } from '@/lib/wiki'
import { helpKeys, postsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchWikiPageByReference, fetchWikiTag } from '@/lib/wiki'
import NotFound from '@/pages/NotFound'
import type { FC } from 'react'
import type { Tag } from '@/types'
import type { Tag, WikiTagReference } from '@/types'
const WikiDetailPage: FC = () => {
const { message, label } = useUITranslation ('shared')
const locale = useLocale ()
const params = useParams ()
const title = params.title ?? ''
const numericReference = /^\d+$/.test (title)
const location = useLocation ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const defaultTag = useMemo (() => ({ name: title, category: 'general' } as Tag), [title])
@@ -36,14 +40,14 @@ const WikiDetailPage: FC = () => {
const version = query.get ('version') || undefined
const { data: wikiPage, isLoading: loading, isSuccess, isFetching, error } = useQuery ({
enabled: Boolean (title) && !(/^\d+$/.test (title)),
queryKey: wikiKeys.show (title, { version }),
queryFn: () => fetchWikiPageByTitle (title, { version }) })
enabled: Boolean (title),
queryKey: wikiKeys.show (title, { version }, locale),
queryFn: () => fetchWikiPageByReference (title, { version }, locale) })
const missingWiki = isSuccess && !(isFetching) && wikiPage == null
const { data: titleIndex, isPending: lookupPending,
isFetching: lookupFetching, error: lookupError } = useQuery ({
enabled: missingWiki,
enabled: missingWiki && !numericReference,
queryKey: helpKeys.titles ('ja'),
queryFn: () => getHelpTitleIndex ('ja'),
staleTime: Infinity })
@@ -53,28 +57,33 @@ const WikiDetailPage: FC = () => {
? titleIndex[lookupTitle] : []), [titleIndex, lookupTitle])
useEffect (() => {
if (missingWiki && !(lookupFetching) && lookupError == null && identifiers.length === 1)
if (missingWiki && !numericReference
&& !(lookupFetching) && lookupError == null && identifiers.length === 1)
navigate (`/help/${ encodeURIComponent (identifiers[0]) }`, { replace: true })
}, [missingWiki, lookupFetching, lookupError, identifiers, navigate])
}, [missingWiki, numericReference, lookupFetching, lookupError, identifiers, navigate])
const effectiveTitle = wikiPage?.title ?? title
const deprecated = wikiPage?.deprecatedAt != null
const tagReference: WikiTagReference = (wikiPage != null || !numericReference)
? { name: effectiveTitle } : { wikiId: title }
const { data: tag, isPending: tagPending,
isFetching: tagFetching, error: tagError } = useQuery ({
enabled: Boolean (effectiveTitle),
queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => fetchTagByName (effectiveTitle) })
enabled: Boolean (title) && (missingWiki || wikiPage != null),
queryKey: wikiKeys.tag (tagReference, locale),
queryFn: () => fetchWikiTag (tagReference, locale) })
const displayTitle = wikiPage?.title ?? tag?.name ?? title
const keys = {
tags: effectiveTitle, match: 'all', page: 1, limit: 8, url: '', title: '',
tags: displayTitle, match: 'all', page: 1, limit: 8, url: '', title: '',
originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
order: 'original_created_at:desc' } as const
const { data } = useQuery ({
enabled: Boolean (effectiveTitle) && !(version),
queryKey: postsKeys.index (keys),
queryFn: () => fetchPosts (keys) })
enabled: Boolean (effectiveTitle) && !(version)
&& (!numericReference || wikiPage != null || tag != null),
queryKey: postsKeys.index (keys, locale),
queryFn: () => fetchPosts (keys, locale) })
const posts = data?.posts || []
useEffect (() => {
@@ -83,85 +92,72 @@ const WikiDetailPage: FC = () => {
WikiIdBus.set (wikiPage.id)
if (wikiPage.title !== title)
navigate (`/wiki/${ encodeURIComponent(wikiPage.title) }`, { replace: true })
if (!numericReference && wikiPage.title !== title)
{
navigate ({ pathname: `/wiki/${ encodeURIComponent (wikiPage.title) }`,
search: location.search, hash: location.hash }, { replace: true })
}
return () => WikiIdBus.set (null)
}, [wikiPage, title, navigate])
useEffect (() => {
if (!(/^\d+$/.test (title)))
return
let active = true
void (async () => {
try
{
const data = await fetchWikiPage (title, { })
if (active)
navigate (`/wiki/${ encodeURIComponent (data.title) }`, { replace: true })
}
catch
{
;
}
}) ()
return () => {
active = false
}
}, [title, navigate])
}, [wikiPage, title, numericReference, navigate, location.search, location.hash])
const displayError = error ?? (missingWiki ? lookupError ?? tagError : null)
if (displayError != null)
return <MainArea><p role="alert">{displayError.message}</p></MainArea>
const waiting = loading || (isFetching && wikiPage == null)
|| (missingWiki && (lookupPending || lookupFetching || tagPending
|| (tagFetching && tag == null) || identifiers.length === 1))
|| (missingWiki && ((!numericReference
&& (lookupPending || lookupFetching || identifiers.length === 1))
|| tagPending || (tagFetching && tag == null)))
if (missingWiki && !(waiting) && (tag == null || version != null))
return <NotFound/>
return (
<MainArea>
<Helmet>
<title>{`${ effectiveTitle }${ deprecated ? '(廃止)' : '' } Wiki | ${ SITE_TITLE }`}</title>
<PageHead title={{
text: `${displayTitle}${deprecated
? message ('labels.deprecatedSuffix').text : ''} Wiki`,
language: deprecated ? message ('labels.deprecatedSuffix').language : locale }}>
{!(wikiPage?.body) && <meta name="robots" content="noindex"/>}
</Helmet>
</PageHead>
{(wikiPage && version) && (
<div className="text-sm flex gap-3 items-center justify-center
border border-gray-700 rounded px-2 py-1 mb-4">
{wikiPage.pred ? (
<PrefetchLink to={`/wiki/${ encodeURIComponent (title) }?version=${ wikiPage.pred }`}>
&lt; 古
</PrefetchLink>) : '(最古)'}
{label ('actions.old')}
</PrefetchLink>) : label ('actions.oldest')}
<span>{wikiPage.updatedAt}</span>
{wikiPage.succ ? (
<PrefetchLink to={`/wiki/${ encodeURIComponent (title) }?version=${ wikiPage.succ }`}>
新 &gt;
</PrefetchLink>) : '(最新)'}
{label ('actions.new')}
</PrefetchLink>) : label ('actions.latest')}
</div>)}
<article className="prose dark:prose-invert mx-auto p-4">
<h1 className="prose-a:no-underline">
<TagLink tag={tag ?? { ...defaultTag,
name: effectiveTitle,
name: displayTitle,
deprecatedAt: wikiPage?.deprecatedAt ?? null }}
withWiki={false}
withCount={false}
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
{deprecated && <span>(廃止)</span>}
{deprecated && label ('labels.deprecatedSuffix')}
</h1>
{waiting ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>}
{waiting
? label ('common:loading')
: <WikiBody title={displayTitle} body={wikiPage?.body}
language={wikiPage?.languageCode ?? (wikiPage?.resourceType === 'tag_description'
? localeLanguage (locale) : 'und')}/>}
{(!(version) && posts.length > 0) && (
<div className="not-prose">
<TabGroup>
<Tab name="広場">
<Tab name={message ('navigation:posts').text}>
<PostList posts={posts}/>
</Tab>
</TabGroup>
+2 -2
ファイルの表示
@@ -36,10 +36,10 @@ describe ('WikiDiffPage', () => {
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/wiki/3/diff',
{ params: { from: '1', to: '2' } },
{ locale: 'ja', params: { from: '1', to: '2' } },
)
})
expect (screen.getByText ('差分対象')).toBeInTheDocument ()
expect (await screen.findByText ('差分対象')).toBeInTheDocument ()
expect (screen.getByText ('same')).toBeInTheDocument ()
expect (screen.getByText ('added line')).toBeInTheDocument ()
expect (screen.getByText ('removed line')).toBeInTheDocument ()
+15 -16
ファイルの表示
@@ -1,43 +1,42 @@
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useQuery } from '@tanstack/react-query'
import { useLocation, useParams } from 'react-router-dom'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { apiGet } from '@/lib/api'
import { wikiKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
import type { WikiPageDiff } from '@/types'
const WikiDiffPage: FC = () => {
const { message, label } = useUITranslation ('wiki')
const locale = useLocale ()
const { id } = useParams ()
const location = useLocation ()
const [diff, setDiff] = useState<WikiPageDiff | null> (null)
const query = new URLSearchParams (location.search)
const from = query.get ('from')
const to = query.get ('to')
const { data: diff } = useQuery ({
queryKey: wikiKeys.diff (id ?? '', { from, to }, locale),
queryFn: () => apiGet<WikiPageDiff> (
`/wiki/${ encodeURIComponent (id ?? '') }/diff`, { locale, params: { from, to } }) })
const displayTitle = diff
? `${ diff.title }${ diff.deprecatedAt != null ? '(廃止)' : '' }`
? `${ diff.title }${ diff.deprecatedAt != null
? message ('shared:labels.deprecatedSuffix').text : '' }`
: ''
useEffect (() => {
void (async () => {
setDiff (await apiGet<WikiPageDiff> (`/wiki/${ id }/diff`, { params: { from, to } }))
}) ()
}, [from, id, to])
return (
<MainArea>
<Helmet>
<title>{`Wiki 差分: ${ displayTitle } | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('diffTitle', { title: displayTitle })}/>
<PageTitle>{displayTitle}</PageTitle>
<div className="prose mx-auto p-4">
{diff
@@ -47,7 +46,7 @@ const WikiDiffPage: FC = () => {
d.type === 'removed' && 'bg-red-200 dark:bg-red-800')}>
{d.content}
</p>)))
: 'Loading...'}
: label ('common:loading')}
</div>
</MainArea>)
}
+6 -4
ファイルの表示
@@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import { textContentOf } from '@/test/text'
import WikiEditPage from '@/pages/wiki/WikiEditPage'
import { buildUser, buildWikiPage } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
@@ -50,9 +51,9 @@ describe ('WikiEditPage', () => {
renderPage ()
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith ('/wiki/9')
expect (api.apiGet).toHaveBeenCalledWith ('/wiki/9', { locale: 'ja' })
})
expect (screen.getAllByRole ('textbox')[0]).toHaveValue ('既存')
expect ((await screen.findAllByRole ('textbox'))[0]).toHaveValue ('既存')
expect (screen.getByLabelText ('本文エディタ')).toHaveValue ('本文')
})
@@ -71,12 +72,13 @@ describe ('WikiEditPage', () => {
expect (api.apiPut).toHaveBeenCalledWith (
'/wiki/9',
expect.any (FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } },
{ locale: 'ja', headers: { 'Content-Type': 'multipart/form-data' } },
)
})
const formData = api.apiPut.mock.calls[0]?.[1] as FormData
expect (formData.get ('title')).toBe ('更新済み')
expect (formData.get ('body')).toBe ('更新本文')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' })
expect (toastApi.toast).toHaveBeenCalledTimes (1)
expect (textContentOf (toastApi.toast.mock.calls[0][0].title)).toBe ('投稿成功!')
})
})
+38 -32
ファイルの表示
@@ -1,15 +1,16 @@
import { useQueryClient } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import MarkdownIt from 'markdown-it'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import MdEditor from 'react-markdown-editor-lite'
import { useParams, useNavigate } from 'react-router-dom'
import { useParams } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { apiGet, apiPut } from '@/lib/api'
import { wikiKeys } from '@/lib/queryKeys'
import { canEditContent } from '@/lib/users'
@@ -36,21 +37,31 @@ type WikiFormField = 'title' | 'body'
const WikiEditPage: FC<Props> = ({ user }) => {
const { message, label } = useUITranslation ('wiki')
const locale = useLocale ()
const editable = canEditContent (user)
const { id } = useParams ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const qc = useQueryClient ()
const [body, setBody] = useState ('')
const [loading, setLoading] = useState (true)
const [title, setTitle] = useState ('')
const source = `${locale}:${id}`
const { data: wiki, isPending: loading } = useQuery ({
queryKey: wikiKeys.show (id ?? '', { }, locale),
queryFn: () => apiGet<WikiPage> (`/wiki/${ encodeURIComponent (id ?? '') }`, { locale }),
enabled: editable })
const [body, setBody] = useState (wiki?.body ?? '')
const [loadedSource, setLoadedSource] = useState (wiki == null ? '' : source)
const pending = loading || loadedSource !== source
const [title, setTitle] = useState (wiki?.title ?? '')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<WikiFormField> ()
const handleSubmit = async () => {
if (pending || !editable)
return
clearValidationErrors ()
const formData = new FormData ()
@@ -60,51 +71,46 @@ const WikiEditPage: FC<Props> = ({ user }) => {
try
{
await apiPut (`/wiki/${ id }`, formData,
{ headers: { 'Content-Type': 'multipart/form-data' } })
qc.setQueryData (wikiKeys.show (title, { }),
(prev: WikiPage) => ({ ...prev, title, body }))
{ locale, headers: { 'Content-Type': 'multipart/form-data' } })
qc.setQueryData (wikiKeys.show (title, { }, locale),
(prev: WikiPage | undefined) =>
prev == null ? prev : { ...prev, title, body })
qc.invalidateQueries ({ queryKey: wikiKeys.root })
toast ({ title: '投稿成功!' })
navigate (`/wiki/${ title }`)
toast ({ title: label ('shared:messages.postSuccess') })
navigate (`/wiki/${ encodeURIComponent (title) }`)
}
catch (e)
{
applyValidationError (e)
toast ({ title: '投稿失敗', description: '入力を確認してください。' })
toast ({ title: label ('shared:messages.postFailed'),
description: label ('shared:messages.checkInputModern') })
}
}
useEffect (() => {
if (!(editable))
if (!(editable) || wiki == null)
return
void (async () => {
setLoading (true)
const data = await apiGet<WikiPage> (`/wiki/${ id }`)
setTitle (data.title)
setBody (data.body)
setLoading (false)
}) ()
}, [editable, id])
setTitle (wiki.title)
setBody (wiki.body)
setLoadedSource (source)
}, [editable, source, wiki])
if (!(editable))
return <Forbidden/>
return (
<MainArea>
<Helmet>
<title>{`Wiki ページを編輯 | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('editPage')}/>
<div className="max-w-xl mx-auto p-4 space-y-4">
<h1 className="text-2xl font-bold mb-2">Wiki ページを編輯</h1>
<h1 className="text-2xl font-bold mb-2">{label ('editPage')}</h1>
{loading ? 'Loading...' : (
{pending ? label ('common:loading') : (
<>
<FieldError messages={baseErrors}/>
{/* タイトル */}
{/* TODO: タグ補完 */}
<FormField label="タイトル" messages={fieldErrors.title}>
<FormField label={label ('shared:labels.title')} messages={fieldErrors.title}>
{({ describedBy, invalid }) => (
<input type="text"
value={title}
@@ -115,7 +121,7 @@ const WikiEditPage: FC<Props> = ({ user }) => {
</FormField>
{/* 本文 */}
<FormField label="本文" messages={fieldErrors.body}>
<FormField label={label ('shared:labels.body')} messages={fieldErrors.body}>
{() => (
<MdEditor value={body}
style={{ height: '500px' }}
@@ -127,7 +133,7 @@ const WikiEditPage: FC<Props> = ({ user }) => {
{/* 送信 */}
<button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
編輯
{label ('shared:actions.edit')}
</button>
</>)}
</div>
+1 -1
ファイルの表示
@@ -36,7 +36,7 @@ const renderWiki = (title: string) => {
<Location/>
<Routes>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
<Route path="/ja/help/:identifier" element={<HelpDetailPage/>}/>
<Route path="/404" element={<div>unexpected redirect</div>}/>
</Routes>
</>, { route })
+1 -1
ファイルの表示
@@ -32,7 +32,7 @@ describe ('WikiHistoryPage', () => {
const link = await screen.findByRole ('link', { name: '旧タグ' })
const marker = screen.getByText ('(廃止)')
expect (link).toHaveAttribute ('href', '/wiki/%E6%97%A7%E3%82%BF%E3%82%B0?version=2')
expect (link).toHaveAttribute ('href', '/ja/wiki/3?version=2')
expect (marker.closest ('a')).toBeNull ()
})
})
+27 -26
ファイルの表示
@@ -1,47 +1,47 @@
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useQuery } from '@tanstack/react-query'
import { useLocation } from 'react-router-dom'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import PrefetchLink from '@/components/PrefetchLink'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api'
import { dateString } from '@/lib/utils'
import PageHead from '@/components/common/PageHead'
import { wikiKeys } from '@/lib/queryKeys'
import { localisedDateString } from '@/lib/utils'
import { fetchWikiChanges } from '@/lib/wiki'
import type { WikiPageChange } from '@/types'
import type { FC } from 'react'
const WikiHistoryPage: FC = () => {
const [changes, setChanges] = useState<WikiPageChange[]> ([])
const { message, label } = useUITranslation ('wiki')
const locale = useLocale ()
const location = useLocation ()
const query = new URLSearchParams (location.search)
const id = query.get ('id')
useEffect (() => {
void (async () => {
setChanges (await apiGet<WikiPageChange[]> ('/wiki/changes', { params: id ? { id } : { } }))
}) ()
}, [id, location.search])
const params = id ? { id } : { }
const { data: changes = [], isLoading, isError } = useQuery ({
queryKey: wikiKeys.changes (params, locale),
queryFn: () => fetchWikiChanges (params, locale) })
return (
<MainArea>
<Helmet>
<title>{`Wiki 変更履歴 | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('history')}/>
<PageTitle>Wiki 履歴</PageTitle>
<PageTitle>{label ('historyHeading')}</PageTitle>
{isLoading && <p>{label ('common:loading')}</p>}
{isError && <p role="alert">{label ('common:fetchError')}</p>}
<table className="table-auto w-full border-collapse">
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th></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 ('shared:labels.title')}</th>
<th className="p-2 text-left">{label ('change')}</th>
<th className="p-2 text-left">{label ('dateTime')}</th>
</tr>
</thead>
<tbody>
@@ -51,25 +51,26 @@ const WikiHistoryPage: FC = () => {
{change.pred != null && (
<PrefetchLink
to={`/wiki/${ change.wikiPage.id }/diff?from=${ change.pred }&to=${ change.revisionId }`}>
差分
{label ('shared:actions.diff')}
</PrefetchLink>)}
</td>
<td className="p-2">
<PrefetchLink
to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.revisionId }`}>
to={`/wiki/${ change.wikiPage.id }?version=${ change.revisionId }`}>
{change.wikiPage.title}
</PrefetchLink>
{change.wikiPage.deprecatedAt != null && <span>(廃止)</span>}
{change.wikiPage.deprecatedAt != null && label ('shared:labels.deprecatedSuffix')}
</td>
<td className="p-2">
{change.pred == null ? '新規' : '更新'}
{change.pred == null
? label ('navigation:create') : label ('shared:actions.update')}
</td>
<td className="p-2">
<PrefetchLink to={`/users/${ change.user.id }`}>
{change.user.name}
</PrefetchLink>
<br/>
{dateString (change.timestamp)}
{localisedDateString (change.timestamp, locale)}
</td>
</tr>))}
</tbody>
+4 -2
ファイルの表示
@@ -1,6 +1,7 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { textContentOf } from '@/test/text'
import WikiNewPage from '@/pages/wiki/WikiNewPage'
import { buildUser, buildWikiPage } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
@@ -50,12 +51,13 @@ describe ('WikiNewPage', () => {
expect (api.apiPost).toHaveBeenCalledWith (
'/wiki',
expect.any (FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } },
{ locale: 'ja', headers: { 'Content-Type': 'multipart/form-data' } },
)
})
const formData = api.apiPost.mock.calls[0]?.[1] as FormData
expect (formData.get ('title')).toBe ('下書き')
expect (formData.get ('body')).toBe ('本文')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' })
expect (toastApi.toast).toHaveBeenCalledTimes (1)
expect (textContentOf (toastApi.toast.mock.calls[0][0].title)).toBe ('投稿成功!')
})
})
+17 -15
ファイルの表示
@@ -1,16 +1,17 @@
import { useQueryClient } from '@tanstack/react-query'
import MarkdownIt from 'markdown-it'
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import MdEditor from 'react-markdown-editor-lite'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import 'react-markdown-editor-lite/lib/index.css'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { apiPost } from '@/lib/api'
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { canEditContent } from '@/lib/users'
@@ -35,10 +36,12 @@ type WikiFormField = 'title' | 'body'
const WikiNewPage: FC<Props> = ({ user }) => {
const { message, label } = useUITranslation ('wiki')
const locale = useLocale ()
const editable = canEditContent (user)
const location = useLocation ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const qc = useQueryClient ()
const query = new URLSearchParams (location.search)
@@ -59,19 +62,20 @@ const WikiNewPage: FC<Props> = ({ user }) => {
try
{
const data = await apiPost<WikiPage> ('/wiki', formData,
{ headers: { 'Content-Type': 'multipart/form-data' } })
{ locale, headers: { 'Content-Type': 'multipart/form-data' } })
await qc.cancelQueries ({ queryKey: wikiKeys.root })
qc.setQueryData (wikiKeys.show (data.title, { }), data)
qc.setQueryData (wikiKeys.show (data.title, { }, locale), data)
await Promise.all ([
qc.invalidateQueries ({ queryKey: wikiKeys.root }),
qc.invalidateQueries ({ queryKey: tagsKeys.root })])
toast ({ title: '投稿成功!' })
toast ({ title: label ('shared:messages.postSuccess') })
navigate (`/wiki/${ encodeURIComponent (data.title) }`)
}
catch (e)
{
applyValidationError (e)
toast ({ title: '投稿失敗', description: '入力を確認してください。' })
toast ({ title: label ('shared:messages.postFailed'),
description: label ('shared:messages.checkInputModern') })
}
}
@@ -80,16 +84,14 @@ const WikiNewPage: FC<Props> = ({ user }) => {
return (
<MainArea>
<Helmet>
<title>{`新規 Wiki ページ | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('newPage')}/>
<div className="max-w-xl mx-auto p-4 space-y-4">
<h1 className="text-2xl font-bold mb-2">新規 Wiki ページ</h1>
<h1 className="text-2xl font-bold mb-2">{label ('newPage')}</h1>
<FieldError messages={baseErrors}/>
{/* タイトル */}
{/* TODO: タグ補完 */}
<FormField label="タイトル" messages={fieldErrors.title}>
<FormField label={label ('shared:labels.title')} messages={fieldErrors.title}>
{({ describedBy, invalid }) => (
<input type="text"
value={title}
@@ -100,7 +102,7 @@ const WikiNewPage: FC<Props> = ({ user }) => {
</FormField>
{/* 本文 */}
<FormField label="本文" messages={fieldErrors.body}>
<FormField label={label ('shared:labels.body')} messages={fieldErrors.body}>
{() => (
<MdEditor value={body}
style={{ height: '500px' }}
@@ -112,7 +114,7 @@ const WikiNewPage: FC<Props> = ({ user }) => {
{/* 送信 */}
<button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
追加
{label ('shared:actions.add')}
</button>
</div>
</MainArea>)
+4 -4
ファイルの表示
@@ -16,8 +16,8 @@ describe ('Wiki resource identity', () => {
vi.mocked (apiGet).mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })])
renderWithProviders (<WikiSearchPage/>)
expect (await screen.findByRole ('link', { name: '虹夏' }))
.toHaveAttribute ('href', '/wiki/%E8%99%B9%E5%A4%8F')
expect (apiGet).toHaveBeenCalledWith ('/wiki', { params: { title: '' } })
.toHaveAttribute ('href', '/ja/wiki/%E8%99%B9%E5%A4%8F')
expect (apiGet).toHaveBeenCalledWith ('/wiki', { locale: 'ja', params: { title: '' } })
})
it ('searches by title on submit', async () => {
@@ -28,7 +28,7 @@ describe ('Wiki resource identity', () => {
fireEvent.change (screen.getAllByRole ('textbox')[0], { target: { value: '検索' } })
fireEvent.click (screen.getByRole ('button', { name: '検索' }))
await waitFor (() => expect (apiGet).toHaveBeenLastCalledWith (
'/wiki', { params: { title: '検索' } }))
'/wiki', { locale: 'ja', params: { title: '検索' } }))
expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument ()
})
@@ -49,7 +49,7 @@ describe ('Wiki resource identity', () => {
await screen.findByRole ('link', { name: '説明' })
expect (screen.getByRole ('link', { name: 'タググループ:一覧' }))
.toHaveAttribute ('href', `/wiki/${ encodeURIComponent (group.title) }`)
.toHaveAttribute ('href', `/ja/wiki/${ encodeURIComponent (group.title) }`)
await waitFor (() => {
expect (errors.mock.calls.some (args => args.some (value =>
String (value).includes ('same key')))).toBe (false)
+26 -22
ファイルの表示
@@ -1,12 +1,15 @@
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useQuery } from '@tanstack/react-query'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import PrefetchLink from '@/components/PrefetchLink'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { apiGet } from '@/lib/api'
import { wikiKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils'
import type { FormEvent , FC } from 'react'
@@ -15,36 +18,37 @@ import type { WikiPage } from '@/types'
const WikiSearchPage: FC = () => {
const { message, label } = useUITranslation ('shared')
const locale = useLocale ()
const [title, setTitle] = useState ('')
const [text, setText] = useState ('')
const [results, setResults] = useState<WikiPage[]> ([])
const search = async () => {
setResults (await apiGet ('/wiki', { params: { title } }))
}
const [submittedTitle, setSubmittedTitle] = useState ('')
const { data: results = [], refetch } = useQuery ({
queryKey: wikiKeys.index ({ title: submittedTitle }, locale),
queryFn: () => apiGet<WikiPage[]> ('/wiki', {
locale, params: { title: submittedTitle } }) })
const handleSearch = (ev: FormEvent) => {
ev.preventDefault ()
search ()
if (submittedTitle === title)
void refetch ()
else
setSubmittedTitle (title)
}
useEffect (() => {
void (async () => {
setResults (await apiGet ('/wiki', { params: { title: '' } }))
}) ()
}, [])
setSubmittedTitle ('')
}, [locale])
return (
<MainArea>
<Helmet>
<title>Wiki | {SITE_TITLE}</title>
</Helmet>
<PageHead title={message ('navigation:wiki')}/>
<div className="max-w-xl">
<PageTitle>Wiki</PageTitle>
<PageTitle>{label ('navigation:wiki')}</PageTitle>
<form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */}
<FormField label="タイトル">
<FormField label={label ('labels.title')}>
{({ invalid }) => (
<input type="text"
value={title}
@@ -53,7 +57,7 @@ const WikiSearchPage: FC = () => {
</FormField>
{/* 内容 */}
<FormField label="内容">
<FormField label={label ('labels.content')}>
{({ invalid }) => (
<input type="text"
value={text}
@@ -65,7 +69,7 @@ const WikiSearchPage: FC = () => {
<div className="py-3">
<button type="submit"
className="bg-blue-500 text-white px-4 py-2 rounded">
検索
{label ('common:search')}
</button>
</div>
</form>
@@ -75,8 +79,8 @@ const WikiSearchPage: FC = () => {
<table className="table-auto w-full border-collapse">
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th className="p-2 text-left">タイトル</th>
<th className="p-2 text-left">最終更新</th>
<th className="p-2 text-left">{label ('labels.title')}</th>
<th className="p-2 text-left">{label ('labels.lastUpdated')}</th>
</tr>
</thead>
<tbody>
@@ -87,7 +91,7 @@ const WikiSearchPage: FC = () => {
<PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}>
{page.title}
</PrefetchLink>
{page.deprecatedAt != null && <span>(廃止)</span>}
{page.deprecatedAt != null && label ('labels.deprecatedSuffix')}
</td>
<td className="p-2">
{dateString (page.updatedAt)}