Reviewed-on: #433 Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
@@ -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>
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする