Reviewed-on: #433 Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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 ()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>)
|
||||
})}
|
||||
|
||||
@@ -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 ()
|
||||
|
||||
@@ -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 }`}>
|
||||
< 古
|
||||
{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 }`}>
|
||||
新 >
|
||||
{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>)
|
||||
}
|
||||
|
||||
@@ -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>)
|
||||
}
|
||||
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>)
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>)}
|
||||
|
||||
@@ -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 ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ()
|
||||
|
||||
@@ -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"/>}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>)
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>)}
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ('入力中')
|
||||
|
||||
@@ -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>)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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=""> </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=""> </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>)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
 へのコメント
|
||||
</>)}
|
||||
</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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ('(廃止)')
|
||||
|
||||
@@ -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 }`}>
|
||||
< 古
|
||||
</PrefetchLink>) : '(最古)'}
|
||||
{label ('actions.old')}
|
||||
</PrefetchLink>) : label ('actions.oldest')}
|
||||
|
||||
<span>{wikiPage.updatedAt}</span>
|
||||
|
||||
{wikiPage.succ ? (
|
||||
<PrefetchLink to={`/wiki/${ encodeURIComponent (title) }?version=${ wikiPage.succ }`}>
|
||||
新 >
|
||||
</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>
|
||||
|
||||
@@ -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 ()
|
||||
|
||||
@@ -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>)
|
||||
}
|
||||
|
||||
@@ -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 ('投稿成功!')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ('投稿成功!')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする