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

Reviewed-on: #433
Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
2026-10-20 20:44:14 +09:00
committed by みてるぞ
コミット 084a090cab
294個のファイルの変更、13978行の追加、3461行の削除
+7 -4
ファイルの表示
@@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { textContentOf } from '@/test/text'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
import { buildMaterial, buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
@@ -59,7 +60,7 @@ describe ('MaterialDetailPage', () => {
renderPage ()
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith ('/materials/8')
expect (api.apiGet).toHaveBeenCalledWith ('/materials/8', { locale: 'ja' })
})
expect (await screen.findByAltText ('素材タグ')).toHaveAttribute ('src', 'image.png')
})
@@ -82,13 +83,15 @@ describe ('MaterialDetailPage', () => {
fireEvent.click (screen.getByRole ('button', { name: '更新' }))
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith ('/materials/8', expect.any (FormData))
expect (api.apiPut).toHaveBeenCalledWith (
'/materials/8', expect.any (FormData), { locale: 'ja' })
})
const formData = api.apiPut.mock.calls[0]?.[1] as FormData
expect (formData.get ('tag')).toBe ('new')
expect (formData.get ('url')).toBe ('https://example.com/ref')
expect (formData.get ('export_paths[legacy_drive]')).toBe ('素材/new.png')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '更新成功!' })
expect (toastApi.toast).toHaveBeenCalledTimes (1)
expect (textContentOf (toastApi.toast.mock.calls[0][0].title)).toBe ('更新成功!')
})
it ('shows not found when material API returns 404', async () => {
@@ -97,7 +100,7 @@ describe ('MaterialDetailPage', () => {
renderPage ()
expect (await screen.findByText ('素材が見つかりませんでした.')).toBeInTheDocument ()
expect (await screen.findByText ('404')).toBeInTheDocument ()
})
it ('shows an error for non-404 material API failures', async () => {
+43 -33
ファイルの表示
@@ -1,8 +1,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom'
import { useLocale } from '@/lib/useLocale'
import TagLink from '@/components/TagLink'
import WikiBody from '@/components/WikiBody'
import FieldError from '@/components/common/FieldError'
@@ -13,14 +13,17 @@ import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import {
fetchMaterial,
updateMaterial,
} from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { localeLanguage } from '@/lib/locales'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import { useUITranslation } from '@/lib/useUITranslation'
import NotFound from '@/pages/NotFound'
import type { FC } from 'react'
@@ -28,23 +31,26 @@ type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
const MaterialDetailPage: FC = () => {
const locale = useLocale ()
const { message, label } = useUITranslation ('materials')
const { id } = useParams ()
const qc = useQueryClient ()
const { data: material, isError, isLoading } = useQuery ({
queryKey: materialsKeys.show (id ?? '', locale),
queryFn: () => fetchMaterial (id ?? '', locale),
enabled: id != null })
const [exportPath, setExportPath] = useState ('')
const [exportPath, setExportPath] = useState (material?.exportPaths.legacyDrive ?? '')
const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState ('')
const [tag, setTag] = useState ('')
const [url, setURL] = useState ('')
const [filePreview, setFilePreview] = useState (
material?.contentType != null ? material.file ?? '' : '')
const [tag, setTag] = useState (material?.tag?.name ?? '')
const [url, setURL] = useState (material?.url ?? '')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<MaterialFormField> ()
const { data: material, isError, isLoading } = useQuery ({
queryKey: materialsKeys.show (id ?? ''),
queryFn: () => fetchMaterial (id ?? ''),
enabled: id != null})
const materialTitle = material
? material.tag?.name ?? `素材 #${ material.id }`
? material.tag?.name ?? message ('unnamed', { id: material.id }).text
: ''
useEffect (() => {
@@ -76,16 +82,16 @@ const MaterialDetailPage: FC = () => {
formData.append ('url', url)
formData.append ('export_paths[legacy_drive]', exportPath)
return await updateMaterial (id ?? '', formData)
return await updateMaterial (id ?? '', formData, locale)
},
onSuccess: async data => {
qc.setQueryData (materialsKeys.show (id ?? ''), data)
qc.setQueryData (materialsKeys.show (id ?? '', locale), data)
await invalidateMaterialQueries ()
toast ({ title: '更新成功!' })
toast ({ title: label ('updated') })
},
onError: error => {
applyValidationError (error)
toast ({ title: '更新失敗……', description: '入力を見直してください.' })
toast ({ title: label ('updateFailed'), description: label ('shared:messages.reviewInput') })
}})
const handleSubmit = () => {
@@ -93,19 +99,20 @@ const MaterialDetailPage: FC = () => {
updateMutation.mutate ()
}
if (!isLoading && !isError && material == null)
return <NotFound/>
return (
<MainArea>
{material && (
<Helmet>
<title>{`${ materialTitle } 素材照会 | ${ SITE_TITLE }`}</title>
</Helmet>)}
<PageHead title={material
? message ('detailTitle', { name: materialTitle }) : message ('title')}/>
{isLoading ? 'Loading...' : isError ? (
{isLoading ? label ('common:loading') : isError ? (
<p className="text-red-600 dark:text-red-300">
素材の取得に失敗しました.
{label ('detailError')}
</p>) : material == null ? (
<p className="text-stone-700 dark:text-stone-300">
素材が見つかりませんでした.
{label ('missing')}
</p>) : (
<>
<PageTitle>
@@ -115,7 +122,7 @@ const MaterialDetailPage: FC = () => {
tag={material.tag}
withWiki={false}
withCount={false}/>)
: materialTitle}
: label ('unnamed', { id: material.id })}
</PageTitle>
{(material.file && material.contentType) && (
@@ -128,17 +135,18 @@ const MaterialDetailPage: FC = () => {
<TabGroup>
{material.tag && (
<Tab name="Wiki">
<Tab name={label ('navigation:wiki')}>
<WikiBody
title={material.tag.name}
language={localeLanguage (locale)}
body={material.wikiPageBody ?? undefined}/>
</Tab>)}
<Tab name="編輯">
<Tab name={label ('shared:actions.edit')}>
<div className="max-w-wl space-y-4 pt-2">
<FieldError messages={baseErrors}/>
<FormField label="タグ" messages={fieldErrors.tag}>
<FormField label={label ('navigation:tags')} messages={fieldErrors.tag}>
{({ describedBy, invalid }) => (
<TagInput
describedBy={describedBy}
@@ -147,7 +155,7 @@ const MaterialDetailPage: FC = () => {
setValue={setTag}/>)}
</FormField>
<FormField label="ファイル" messages={fieldErrors.file}>
<FormField label={label ('file')} messages={fieldErrors.file}>
{({ describedBy, invalid }) => (
<>
<input
@@ -165,7 +173,8 @@ const MaterialDetailPage: FC = () => {
(/image\/.*/.test (file.type) && (
<img
src={filePreview}
alt="preview"
alt={message ('preview').text}
lang={message ('preview').language}
className="mt-2 max-h-48 rounded border"/>))
|| (/video\/.*/.test (file.type) && (
<video
@@ -179,12 +188,12 @@ const MaterialDetailPage: FC = () => {
className="mt-2 max-h-48"/>))
|| (
<p className="text-red-600 dark:text-red-400">
その形式のファイルには対応していません.
{label ('unsupportedFile')}
</p>))}
</>)}
</FormField>
<FormField label="参考 URL" messages={fieldErrors.url}>
<FormField label={label ('referenceUrl')} messages={fieldErrors.url}>
{({ describedBy, invalid }) => (
<input
type="url"
@@ -195,13 +204,14 @@ const MaterialDetailPage: FC = () => {
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="ZIP 出力パス" messages={fieldErrors.exportPaths}>
<FormField label={label ('exportPath')} messages={fieldErrors.exportPaths}>
{({ describedBy, invalid }) => (
<input
type="text"
value={exportPath}
onChange={e => setExportPath (e.target.value)}
placeholder="伊地知ニジカ/表情/泣き.png"
placeholder={message ('exportPlaceholder').text}
lang={message ('exportPlaceholder').language}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
@@ -213,7 +223,7 @@ const MaterialDetailPage: FC = () => {
className="rounded bg-blue-600 px-4 py-2 text-white
disabled:bg-gray-400"
disabled={updateMutation.isPending}>
更新
{label ('shared:actions.update')}
</Button>
</div>
</div>
+36 -29
ファイルの表示
@@ -1,28 +1,31 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import PrefetchLink from '@/components/PrefetchLink'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchMaterialChanges } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { dateString, inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import { localisedDateString, inputClass } from '@/lib/utils'
import type { FC, FormEvent } from 'react'
const MaterialHistoryPage: FC = () => {
const { message, label } = useUITranslation ('history')
const locale = useLocale ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const location = useLocation ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const query = new URLSearchParams (location.search)
const materialId = query.get ('material_id') ?? ''
@@ -47,13 +50,13 @@ const MaterialHistoryPage: FC = () => {
...(tag && { tag }),
...(eventType && { eventType }),
page,
limit }),
limit }, locale),
queryFn: () => fetchMaterialChanges ({
...(materialId && { materialId }),
...(tag && { tag }),
...(eventType && { eventType }),
page,
limit }) })
limit }, locale) })
const versions = data?.versions ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
@@ -80,12 +83,10 @@ const MaterialHistoryPage: FC = () => {
return (
<MainArea>
<Helmet>
<title>{`素材履歴 | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('materialTitle')}/>
<div className="space-y-5">
<PageTitle>素材履歴</PageTitle>
<PageTitle>{label ('materialTitle')}</PageTitle>
<form
onSubmit={handleSearch}
@@ -93,20 +94,24 @@ const MaterialHistoryPage: FC = () => {
text-stone-900 dark:border-stone-700 dark:bg-stone-900
dark:text-stone-100">
<div className="space-y-3">
<FormField label="素材 ID">
<FormField label={label ('materialId')}>
{({ invalid }) => (
<input
type="text"
value={materialIdInput}
aria-label={message ('materialId').text}
lang={message ('materialId').language}
onChange={e => setMaterialIdInput (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="タグ名">
<FormField label={label ('tagName')}>
{({ invalid }) => (
<input
type="text"
value={tagInput}
aria-label={message ('tagName').text}
lang={message ('tagName').language}
onChange={e => setTagInput (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
@@ -117,16 +122,16 @@ const MaterialHistoryPage: FC = () => {
className="mt-4 rounded-full border border-stone-300 bg-white px-4 py-2
text-sm text-stone-900 hover:bg-stone-100 dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800">
絞り込む
{label ('filter')}
</button>
</form>
{isLoading
? 'Loading...'
: isError
? label ('common:loading')
: (isError
? (
<p className="text-red-600 dark:text-red-300">
素材履歴の取得に失敗しました.
<p className="text-red-600 dark:text-red-300" role="alert">
{label ('materialError')}
</p>)
: (
<>
@@ -145,14 +150,14 @@ const MaterialHistoryPage: FC = () => {
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th className="p-2 text-left">日時</th>
<th className="p-2 text-left">素材 ID</th>
<th className="p-2 text-left">版</th>
<th className="p-2 text-left">タグ名</th>
<th className="p-2 text-left">ファイル名</th>
<th className="p-2 text-left">URL</th>
<th className="p-2 text-left">export_path</th>
<th className="p-2 text-left">操作者</th>
<th className="p-2 text-left">{label ('wiki:dateTime')}</th>
<th className="p-2 text-left">{label ('materialId')}</th>
<th className="p-2 text-left">{label ('version')}</th>
<th className="p-2 text-left">{label ('tagName')}</th>
<th className="p-2 text-left">{label ('filename')}</th>
<th className="p-2 text-left">{label ('shared:url')}</th>
<th className="p-2 text-left">{label ('exportPath')}</th>
<th className="p-2 text-left">{label ('actor')}</th>
</tr>
</thead>
@@ -161,7 +166,9 @@ const MaterialHistoryPage: FC = () => {
<tr
key={version.id}
className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">{dateString (version.createdAt)}</td>
<td className="p-2">
{localisedDateString (version.createdAt, locale)}
</td>
<td className="p-2">
<PrefetchLink to={`/materials/${ version.materialId }`}>
#{version.materialId}
@@ -177,7 +184,7 @@ const MaterialHistoryPage: FC = () => {
<td className="p-2">
{version.createdByUser
? version.createdByUser.name || `#${ version.createdByUser.id }`
: 'bot 操作'}
: label ('bot')}
</td>
</tr>))}
</tbody>
@@ -185,7 +192,7 @@ const MaterialHistoryPage: FC = () => {
</div>
<Pagination page={page} totalPages={totalPages}/>
</>)}
</>))}
</div>
</MainArea>)
}
+10 -9
ファイルの表示
@@ -23,7 +23,7 @@ describe ('MaterialListPage', () => {
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/materials',
{ params: {
{ locale: 'ja', params: {
tag_state: 'all',
media_kind: 'all',
sort: 'created_at',
@@ -53,8 +53,9 @@ describe ('MaterialListPage', () => {
renderWithProviders (<MaterialListPage/>, { route: '/materials' })
expect ((await screen.findByText ('素材タグ')).closest ('a'))
.toHaveAttribute ('href', '/materials/8')
expect (screen.getByText (/画像 \/ 令和8年1月2日/)).toBeInTheDocument ()
.toHaveAttribute ('href', '/ja/materials/8')
expect (screen.getByText ('令和8年1月2日(金) 12:04:05').parentElement)
.toHaveTextContent ('画像 / 令和8年1月2日')
})
it ('submits list filters as material index params', async () => {
@@ -93,7 +94,7 @@ describe ('MaterialListPage', () => {
expect (await screen.findByRole (
'link',
{ name: '素材検索トップへ戻る' },
)).toHaveAttribute ('href', '/materials?material_filter=any')
)).toHaveAttribute ('href', '/ja/materials?material_filter=any')
})
it ('uses list view for detailed material rows', async () => {
@@ -113,7 +114,7 @@ describe ('MaterialListPage', () => {
expect (await screen.findByRole ('link', { name: '詳細素材' })).toHaveAttribute (
'href',
'/materials/9',
'/ja/materials/9',
)
expect (screen.getByText ('サイズ:')).toBeInTheDocument ()
expect (screen.getByText ('2.0 KB')).toBeInTheDocument ()
@@ -153,11 +154,11 @@ describe ('MaterialListPage', () => {
expect (screen.getByRole ('link', { name: '泣き' })).toHaveAttribute (
'href',
'/materials?tag_id=30&include_descendants=1&group_by=parent_tag&material_filter=present',
)
'/ja/materials?tag_id=30&include_descendants=1'
+ '&group_by=parent_tag&material_filter=present')
expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute (
'href',
'/materials?material_filter=present',
'/ja/materials?material_filter=present',
)
})
@@ -174,7 +175,7 @@ describe ('MaterialListPage', () => {
expect (await screen.findByText ('選択中タグが見つかりません.')).toBeInTheDocument ()
expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute (
'href',
'/materials?material_filter=present',
'/ja/materials?material_filter=present',
)
})
})
+153 -128
ファイルの表示
@@ -1,19 +1,21 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import nikumaru from '@/assets/fonts/nikumaru.otf'
import PrefetchLink from '@/components/PrefetchLink'
import LocalisedText from '@/components/LocalisedText'
import DateTimeField from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { fetchMaterials, parseMaterialFilter } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { cn, dateString, inputClass } from '@/lib/utils'
import { cn, localisedDateString, inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, FormEvent } from 'react'
@@ -29,28 +31,28 @@ import type { FetchMaterialsParams,
MaterialIndexView } from '@/types'
const MEDIA_KIND_LABELS: Record<Material['mediaKind'], string> = {
image: '画像',
video: '動画',
audio: '音声',
file_other: 'その他ファイル',
url_only: 'URL のみ'}
image: 'media.image',
video: 'media.video',
audio: 'media.audio',
file_other: 'media.file_other',
url_only: 'media.url_only' }
const MEDIA_FILTER_LABELS: Record<MaterialIndexMediaKind, string> = {
all: 'すべて',
image: '画像',
video: '動画',
audio: '音声',
file_other: 'その他ファイル',
url_only: '外部リンクのみ'}
all: 'mediaFilter.all',
image: 'mediaFilter.image',
video: 'mediaFilter.video',
audio: 'mediaFilter.audio',
file_other: 'mediaFilter.file_other',
url_only: 'mediaFilter.url_only' }
const SORT_LABELS: Record<MaterialIndexSort, string> = {
created_at: '作成日時',
updated_at: '更新日時',
tag_name: 'タグ名',
media_kind: '種類',
file_byte_size: '容量',
version_no: '版',
id: 'Id.'}
created_at: 'sort.created_at',
updated_at: 'sort.updated_at',
tag_name: 'sort.tag_name',
media_kind: 'sort.media_kind',
file_byte_size: 'sort.file_byte_size',
version_no: 'sort.version_no',
id: 'sort.id' }
const setIf = (qs: URLSearchParams, key: string, value: string | null) => {
@@ -77,10 +79,6 @@ const fileSizeText = (bytes: number | null): string => {
}
const materialTitle = (material: Material): string =>
material.tag?.name ?? `素材 #${ material.id }`
const groupedTagPath = (
tagId: number,
materialFilter: MaterialFilter,
@@ -131,87 +129,106 @@ const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
</div>)
const MaterialCard: FC<{ material: Material }> = ({ material }) => (
<article className="w-[180px] justify-self-center">
<PrefetchLink to={`/materials/${ material.id }`} className="block">
<MaterialThumb material={material}/>
<div className="mt-2 w-[180px]">
<p className="truncate text-sm font-medium text-stone-900 dark:text-stone-100">
{materialTitle (material)}
</p>
<p className="truncate text-xs text-stone-600 dark:text-stone-300">
{MEDIA_KIND_LABELS[material.mediaKind]} / {dateString (material.createdAt)}
</p>
</div>
</PrefetchLink>
</article>)
const MaterialListItem: FC<{ material: Material }> = ({ material }) => (
<article
className="rounded-lg border border-stone-200 bg-white p-3 text-stone-900
shadow-sm dark:border-stone-700 dark:bg-stone-900 dark:text-stone-100">
<div className="flex gap-3">
<MaterialThumb material={material}/>
<div className="min-w-0 flex-1 space-y-2">
<div>
<PrefetchLink
to={`/materials/${ material.id }`}
className="font-medium text-sky-700 underline underline-offset-2
dark:text-sky-300">
{materialTitle (material)}
</PrefetchLink>
const MaterialCard: FC<{ material: Material }> = ({ material }) => {
const locale = useLocale ()
const { label } = useUITranslation ('materials')
return (
<article className="w-[180px] justify-self-center">
<PrefetchLink to={`/materials/${ material.id }`} className="block">
<MaterialThumb material={material}/>
<div className="mt-2 w-[180px]">
<p className="truncate text-sm font-medium text-stone-900 dark:text-stone-100">
{material.tag?.name ?? label ('unnamed', { id: material.id })}
</p>
<p className="text-xs text-stone-600 dark:text-stone-300">
{label (MEDIA_KIND_LABELS[material.mediaKind])} /{' '}
<LocalisedText language={locale}>
{localisedDateString (material.createdAt, locale)}
</LocalisedText>
</p>
</div>
<dl className="space-y-1 text-sm text-stone-600 dark:text-stone-300">
</PrefetchLink>
</article>)
}
const MaterialListItem: FC<{ material: Material }> = ({ material }) => {
const locale = useLocale ()
const { label } = useUITranslation ('materials')
return (
<article
className="rounded-lg border border-stone-200 bg-white p-3 text-stone-900
shadow-sm dark:border-stone-700 dark:bg-stone-900 dark:text-stone-100">
<div className="flex flex-wrap gap-3">
<MaterialThumb material={material}/>
<div className="min-w-0 flex-1 space-y-2">
<div>
<dt className="inline">種類: </dt>
<dd className="inline">{MEDIA_KIND_LABELS[material.mediaKind]}</dd>
<PrefetchLink
to={`/materials/${ material.id }`}
className="font-medium text-sky-700 underline underline-offset-2
dark:text-sky-300">
{material.tag?.name ?? label ('unnamed', { id: material.id })}
</PrefetchLink>
</div>
{material.fileByteSize != null && (
<div>
<dt className="inline">サイズ: </dt>
<dd className="inline">{fileSizeText (material.fileByteSize)}</dd>
</div>)}
{material.url && (
<div>
<dt className="inline">URL: </dt>
<dd className="inline break-all">{material.url}</dd>
</div>)}
<div>
<dt className="inline">作成: </dt>
<dd className="inline">{dateString (material.createdAt)}</dd>
</div>
</dl>
<dl className="space-y-1 text-sm text-stone-600 dark:text-stone-300">
<div>
<dt className="inline">{label ('kindPrefix')}</dt>
<dd className="inline">{label (MEDIA_KIND_LABELS[material.mediaKind])}</dd>
</div>
{material.fileByteSize != null && (
<div>
<dt className="inline">{label ('sizePrefix')}</dt>
<dd className="inline">{fileSizeText (material.fileByteSize)}</dd>
</div>)}
{material.url && (
<div>
<dt className="inline">{label ('urlPrefix')}</dt>
<dd className="inline break-all">{material.url}</dd>
</div>)}
<div>
<dt className="inline">{label ('createdPrefix')}</dt>
<dd className="inline">
<LocalisedText language={locale}>
{localisedDateString (material.createdAt, locale)}
</LocalisedText>
</dd>
</div>
</dl>
</div>
</div>
</div>
</article>)
</article>)
}
const GroupHeading: FC<{
count: number
materialFilter: MaterialFilter
title: string
tagId: number
}> = ({ count, materialFilter, title, tagId }) => (
<div className="flex items-center gap-2 border-b border-stone-200 pb-2
dark:border-stone-700">
<PrefetchLink
to={groupedTagPath (tagId, materialFilter)}
className="font-medium text-sky-700 underline underline-offset-2
dark:text-sky-300 w-full">
{title}
</PrefetchLink>
<div className="text-right w-auto">
<span className="text-nowrap text-sm text-stone-600 dark:text-stone-300">
{count} 件
</span>
</div>
</div>)
tagId: number }> = ({ count, materialFilter, title, tagId }) => {
const { label } = useUITranslation ('materials')
return (
<div className="flex flex-wrap items-center gap-2 border-b border-stone-200 pb-2
dark:border-stone-700">
<PrefetchLink
to={groupedTagPath (tagId, materialFilter)}
className="min-w-0 flex-1 font-medium text-sky-700 underline underline-offset-2
dark:text-sky-300">
{title}
</PrefetchLink>
<div className="text-right w-auto">
<span className="text-sm text-stone-600 dark:text-stone-300">
{label ('shared:labels.resultsCount', { count })}
</span>
</div>
</div>)
}
const MaterialListPage: FC = () => {
const locale = useLocale ()
const { message, label } = useUITranslation ('materials')
const location = useLocation ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const query = useMemo (() => new URLSearchParams (location.search), [location.search])
const materialFilter = parseMaterialFilter (query.get ('material_filter'), 'present')
@@ -277,8 +294,8 @@ const MaterialListPage: FC = () => {
page,
limit}
const { data, isLoading, isError } = useQuery ({
queryKey: materialsKeys.index (keys),
queryFn: () => fetchMaterials (keys)})
queryKey: materialsKeys.index (keys, locale),
queryFn: () => fetchMaterials (keys, locale)})
const tagScope = data?.tagScope ?? null
const materials = data?.materials ?? []
const groups = data?.groups ?? []
@@ -385,7 +402,7 @@ const MaterialListPage: FC = () => {
<section className="space-y-3">
<div className="border-b border-stone-200 pb-2 dark:border-stone-700">
<span className="font-medium text-stone-900 dark:text-stone-100">
その他
{label ('other')}
</span>
</div>
{renderMaterialCollection (ungroupedMaterials)}
@@ -394,7 +411,7 @@ const MaterialListPage: FC = () => {
return (
<MainArea>
<Helmet>
<PageHead title={message ('title')}>
<style>
{`
@font-face
@@ -403,12 +420,11 @@ const MaterialListPage: FC = () => {
src: url(${ nikumaru }) format('opentype');
}`}
</style>
<title>{`素材管理 | ${ SITE_TITLE }`}</title>
</Helmet>
</PageHead>
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle className="my-auto">素材管理</PageTitle>
<PageTitle className="my-auto">{label ('title')}</PageTitle>
<div className="flex flex-wrap gap-2">
{/* TODO: 局所出力を可能にする */}
{/* <a
@@ -429,25 +445,25 @@ const MaterialListPage: FC = () => {
dark:border-sky-800 dark:bg-sky-950 dark:text-sky-100">
<span>
{tagScope.tag.name}
{tagScope.includeDescendants ? ' 配下の素材を表示中' : ' の素材を表示中'}
{label (tagScope.includeDescendants ? 'descendantScope' : 'tagScope')}
</span>
<PrefetchLink
to={clearedTagSelectionPath (location.search, materialFilter)}
className="font-medium underline underline-offset-2
text-sky-700 dark:text-sky-300">
タグ選択を解除
{label ('clearTag')}
</PrefetchLink>
</div>)}
{(tagId != null && tagScope == null) && (
<div className="flex flex-wrap items-center gap-3 rounded-lg border
border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900
dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100">
<span>選択中タグが見つかりません.</span>
<span>{label ('tagMissing')}</span>
<PrefetchLink
to={clearedTagSelectionPath (location.search, materialFilter)}
className="font-medium underline underline-offset-2
text-amber-800 dark:text-amber-200">
タグ選択を解除
{label ('clearTag')}
</PrefetchLink>
</div>)}
@@ -455,7 +471,7 @@ const MaterialListPage: FC = () => {
<PrefetchLink
to={`/materials?material_filter=${ materialFilter }`}
className="text-sm text-sky-700 underline underline-offset-2 dark:text-sky-300">
素材検索トップへ戻る
{label ('searchTop')}
</PrefetchLink>)}
<form
@@ -464,44 +480,49 @@ const MaterialListPage: FC = () => {
text-stone-900 dark:border-stone-700 dark:bg-stone-900
dark:text-stone-100">
<div className="space-y-3">
<FormField label="検索">
<FormField label={label ('common:search')}>
{({ invalid }) => (
<input
type="search"
value={q}
onChange={e => setQ (e.target.value)}
placeholder="タグ名 / URL / ファイル名"
placeholder={message ('searchPlaceholder').text}
lang={message ('searchPlaceholder').language}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="タグ状態">
<FormField label={label ('tagState')}>
{({ invalid }) => (
<select
value={tagStateInput}
onChange={e => setTagStateInput (
e.target.value as MaterialIndexTagState)}
className={inputClass (invalid)}>
<option value="all">すべて</option>
<option value="tagged">タグあり</option>
<option value="all" lang={message ('mediaFilter.all').language}>
{message ('mediaFilter.all').text}
</option>
<option value="tagged" lang={message ('tagged').language}>
{message ('tagged').text}
</option>
<option value="untagged"></option>
</select>)}
</FormField>
<FormField label="メディア">
<FormField label={label ('mediaLabel')}>
{({ invalid }) => (
<select
value={mediaKindInput}
onChange={e => setMediaKindInput (
e.target.value as MaterialIndexMediaKind)}
className={inputClass (invalid)}>
{Object.entries (MEDIA_FILTER_LABELS).map (([value, label]) => (
<option key={value} value={value}>
{label}
{Object.entries (MEDIA_FILTER_LABELS).map (([value, key]) => (
<option key={value} value={value} lang={message (key).language}>
{message (key).text}
</option>))}
</select>)}
</FormField>
<FormField label="作成日時">
<FormField label={label ('sort.created_at')}>
{() => (
<div className="flex flex-wrap items-center gap-2">
<DateTimeField value={createdFrom ?? undefined} onChange={setCreatedFrom}/>
@@ -510,7 +531,7 @@ const MaterialListPage: FC = () => {
</div>)}
</FormField>
<FormField label="更新日時">
<FormField label={label ('sort.updated_at')}>
{() => (
<div className="flex flex-wrap items-center gap-2">
<DateTimeField value={updatedFrom ?? undefined} onChange={setUpdatedFrom}/>
@@ -525,7 +546,7 @@ const MaterialListPage: FC = () => {
type="submit"
className="rounded bg-sky-600 px-4 py-2 text-white hover:bg-sky-700
dark:bg-sky-500 dark:text-stone-950 dark:hover:bg-sky-400">
検索
{label ('common:search')}
</button>
</div>
</form>
@@ -544,7 +565,7 @@ const MaterialListPage: FC = () => {
: [
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'])}>
アイコン
{label ('cardView')}
</button>
<button
type="button"
@@ -558,7 +579,7 @@ const MaterialListPage: FC = () => {
: [
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'])}>
詳細
{label ('listView')}
</button>
</div>
@@ -567,34 +588,38 @@ const MaterialListPage: FC = () => {
value={sort}
onChange={e => updateQuery ({ sort: e.target.value, page: '1' })}
className={inputClass (false, 'w-auto')}>
{Object.entries (SORT_LABELS).map (([value, label]) => (
<option key={value} value={value}>
{label}
{Object.entries (SORT_LABELS).map (([value, key]) => (
<option key={value} value={value} lang={message (key).language}>
{message (key).text}
</option>))}
</select>
<select
value={direction}
onChange={e => updateQuery ({ direction: e.target.value, page: '1' })}
className={inputClass (false, 'w-auto')}>
<option value="desc">降順</option>
<option value="asc">昇順</option>
<option value="desc" lang={message ('descending').language}>
{message ('descending').text}
</option>
<option value="asc" lang={message ('ascending').language}>
{message ('ascending').text}
</option>
</select>
</div>
</div>
{isLoading && <p>Loading...</p>}
{isLoading && <p>{label ('common:loading')}</p>}
{isError && (
<p className="text-red-600 dark:text-red-300">素材一覧の取得に失敗しました.</p>)}
<p className="text-red-600 dark:text-red-300">{label ('listError')}</p>)}
{(!isLoading && !isError && materials.length === 0) && (
<p>
素材はありません。
{label ('empty')}
{(tagScope && ['character', 'material'].includes (tagScope.tag.category)) && (
<>
<PrefetchLink
to={materialNewPath (tagScope.tag.name, location.pathname + location.search)}
className="font-medium underline underline-offset-2
text-sky-700 dark:text-sky-300">
追加してください
{label ('pleaseAdd')}
</PrefetchLink>。
</>)}
</p>)}
+14 -11
ファイルの表示
@@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Route, Routes, useLocation } from 'react-router-dom'
import { textContentOf } from '@/test/text'
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
import { renderWithProviders } from '@/test/render'
@@ -33,7 +34,7 @@ describe ('MaterialNewPage', () => {
it ('initializes tag from query and submits form data', async () => {
api.apiPost.mockResolvedValueOnce ({})
renderWithProviders (<MaterialNewPage/>, { route: '/materials/new?tag=%E8%99%B9%E5%A4%8F' })
renderWithProviders (<MaterialNewPage/>, { route: '/ja/materials/new?tag=%E8%99%B9%E5%A4%8F' })
expect (screen.getAllByRole ('textbox')[0]).toHaveValue ('虹夏')
fireEvent.change (screen.getAllByRole ('textbox')[1], {
@@ -42,12 +43,14 @@ describe ('MaterialNewPage', () => {
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
await waitFor (() => {
expect (api.apiPost).toHaveBeenCalledWith ('/materials', expect.any (FormData))
expect (api.apiPost).toHaveBeenCalledWith (
'/materials', expect.any (FormData), { locale: 'ja' })
})
const formData = api.apiPost.mock.calls[0]?.[1] as FormData
expect (formData.get ('tag')).toBe ('虹夏')
expect (formData.get ('url')).toBe ('https://example.com/ref')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '送信成功!' })
expect (toastApi.toast).toHaveBeenCalledTimes (1)
expect (textContentOf (toastApi.toast.mock.calls[0][0].title)).toBe ('送信成功!')
})
it ('shows validation errors for file and url fields', async () => {
@@ -81,12 +84,12 @@ describe ('MaterialNewPage', () => {
renderWithProviders (
<Routes>
<Route path="/materials/new" element={<MaterialNewPage/>}/>
<Route path="/materials" element={<LocationView/>}/>
<Route path="/ja/materials/new" element={<MaterialNewPage/>}/>
<Route path="/ja/materials" element={<LocationView/>}/>
</Routes>,
{
route:
'/materials/new?tag=%E8%99%B9%E5%A4%8F'
'/ja/materials/new?tag=%E8%99%B9%E5%A4%8F'
+ '&return_to=%2Fmaterials%3Ftag_id%3D20',
},
)
@@ -96,7 +99,7 @@ describe ('MaterialNewPage', () => {
})
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('/materials?tag_id=20')).toBeInTheDocument ()
expect (await screen.findByText ('/ja/materials?tag_id=20')).toBeInTheDocument ()
})
it ('ignores unsafe return_to values after successful creation', async () => {
@@ -104,12 +107,12 @@ describe ('MaterialNewPage', () => {
renderWithProviders (
<Routes>
<Route path="/materials/new" element={<MaterialNewPage/>}/>
<Route path="/materials" element={<LocationView/>}/>
<Route path="/ja/materials/new" element={<MaterialNewPage/>}/>
<Route path="/ja/materials" element={<LocationView/>}/>
</Routes>,
{
route:
'/materials/new?tag=%E8%99%B9%E5%A4%8F'
'/ja/materials/new?tag=%E8%99%B9%E5%A4%8F'
+ '&return_to=https%3A%2F%2Fexample.com%2Fevil',
},
)
@@ -119,6 +122,6 @@ describe ('MaterialNewPage', () => {
})
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('/materials')).toBeInTheDocument ()
expect (await screen.findByText ('/ja/materials')).toBeInTheDocument ()
})
})
+25 -20
ファイルの表示
@@ -1,8 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form'
import FormField from '@/components/common/FormField'
@@ -11,11 +11,13 @@ import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { createMaterial } from '@/lib/materials'
import { unlocalisedPath } from '@/lib/locales'
import { materialsKeys } from '@/lib/queryKeys'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react'
@@ -23,14 +25,17 @@ type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
const MaterialNewPage: FC = () => {
const locale = useLocale ()
const { message, label } = useUITranslation ('materials')
const qc = useQueryClient ()
const location = useLocation ()
const query = new URLSearchParams (location.search)
const tagQuery = query.get ('tag') ?? ''
const returnToQuery = query.get ('return_to') ?? ''
const safeReturnTo = returnToQuery.startsWith ('/materials') ? returnToQuery : ''
const safeReturnTo =
unlocalisedPath (returnToQuery).startsWith ('/materials') ? returnToQuery : ''
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState ('')
@@ -51,16 +56,16 @@ const MaterialNewPage: FC = () => {
formData.append ('url', url)
formData.append ('export_paths[legacy_drive]', exportPath)
return await createMaterial (formData)
return await createMaterial (formData, locale)
},
onSuccess: async () => {
await qc.invalidateQueries ({ queryKey: materialsKeys.root })
toast ({ title: '送信成功!' })
toast ({ title: label ('sent') })
navigate (safeReturnTo || '/materials')
},
onError: error => {
applyValidationError (error)
toast ({ title: '送信失敗……', description: '入力を見直してください.' })
toast ({ title: label ('sendFailed'), description: label ('shared:messages.reviewInput') })
}})
const handleSubmit = () => {
@@ -70,16 +75,14 @@ const MaterialNewPage: FC = () => {
return (
<MainArea>
<Helmet>
<title>{`素材追加 | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('newTitle')}/>
<Form>
<PageTitle>素材追加</PageTitle>
<PageTitle>{label ('newTitle')}</PageTitle>
<FieldError messages={baseErrors}/>
{/* タグ */}
<FormField label="タグ" messages={fieldErrors.tag}>
<FormField label={label ('navigation:tags')} messages={fieldErrors.tag}>
{({ describedBy, invalid }) => (
<TagInput
describedBy={describedBy}
@@ -89,7 +92,7 @@ const MaterialNewPage: FC = () => {
</FormField>
{/* ファイル */}
<FormField label="ファイル" messages={fieldErrors.file}>
<FormField label={label ('file')} messages={fieldErrors.file}>
{({ describedBy, invalid }) => (
<>
<input
@@ -106,7 +109,8 @@ const MaterialNewPage: FC = () => {
(/image\/.*/.test (file.type) && (
<img
src={filePreview}
alt="preview"
alt={message ('preview').text}
lang={message ('preview').language}
className="mt-2 max-h-48 rounded border"/>))
|| (/video\/.*/.test (file.type) && (
<video
@@ -120,13 +124,13 @@ const MaterialNewPage: FC = () => {
className="mt-2 max-h-48"/>))
|| (
<p className="text-red-600 dark:text-red-400">
その形式のファイルには対応していません.
{label ('unsupportedFile')}
</p>))}
</>)}
</FormField>
{/* 参考 URL */}
<FormField label="参考 URL" messages={fieldErrors.url}>
<FormField label={label ('referenceUrl')} messages={fieldErrors.url}>
{({ describedBy, invalid }) => (
<input
type="url"
@@ -138,14 +142,15 @@ const MaterialNewPage: FC = () => {
</FormField>
<FormField
label="ZIP 出力パス"
label={label ('exportPath')}
messages={fieldErrors.exportPaths}>
{({ describedBy, invalid }) => (
<input
type="text"
value={exportPath}
onChange={e => setExportPath (e.target.value)}
placeholder="伊地知ニジカ/表情/泣き.png"
placeholder={message ('exportPlaceholder').text}
lang={message ('exportPlaceholder').language}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
@@ -156,7 +161,7 @@ const MaterialNewPage: FC = () => {
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
disabled={createMutation.isPending}>
追加
{label ('shared:actions.add')}
</Button>
</Form>
</MainArea>)
+43 -50
ファイルの表示
@@ -1,28 +1,29 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocale } from '@/lib/useLocale'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { createMaterialSyncSuppression, fetchMaterialSyncSuppressions } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils'
import { localisedDateString, inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, FormEvent } from 'react'
import type { MaterialSyncSuppressionSourceKind } from '@/types'
const SOURCE_KIND_LABELS: Record<MaterialSyncSuppressionSourceKind, string> = {
uri: 'URI',
google_drive_path: 'Google Drive ファイル',
google_drive_path_prefix: 'Google Drive フォルダ',
google_drive_file: 'Google Drive ファイル Id.',
legacy_drive_path: '汎用ファイル',
legacy_drive_path_prefix: '汎用フォルダ' }
uri: 'suppressions.sources.uri',
google_drive_path: 'suppressions.sources.google_drive_path',
google_drive_path_prefix: 'suppressions.sources.google_drive_path_prefix',
google_drive_file: 'suppressions.sources.google_drive_file',
legacy_drive_path: 'suppressions.sources.legacy_drive_path',
legacy_drive_path_prefix: 'suppressions.sources.legacy_drive_path_prefix' }
const REASONS = [
'copyright_high_risk',
@@ -36,22 +37,9 @@ const REASONS = [
type MaterialSyncSuppressionReason = typeof REASONS[number]
const REASON_NAMES: Record<MaterialSyncSuppressionReason, string> = {
['copyright_high_risk']: '著作権への懸念',
['copyright_takedown']: '著作者からの申出',
['adult_or_sensitive']: '成人向け',
['personal_information']: '個人情報',
['malware_or_dangerous_file']: '危険なソフトウェア',
['duplicate_or_low_quality']: '重複',
['source_owner_request']: '同期元管理者からの申出',
['other']: 'その他' } as const
const reasonName = (reason: string): string =>
REASON_NAMES[reason as MaterialSyncSuppressionReason] ?? reason
const MaterialSyncSuppressionsPage: FC = () => {
const locale = useLocale ()
const { message, label } = useUITranslation ('materials')
const qc = useQueryClient ()
const [sourceKind, setSourceKind] =
useState<MaterialSyncSuppressionSourceKind> ('uri')
@@ -61,8 +49,8 @@ const MaterialSyncSuppressionsPage: FC = () => {
const [reason, setReason] = useState<MaterialSyncSuppressionReason> (REASONS[0])
const { data, isError, isLoading } = useQuery ({
queryKey: materialsKeys.suppressions (),
queryFn: fetchMaterialSyncSuppressions})
queryKey: materialsKeys.suppressions (locale),
queryFn: () => fetchMaterialSyncSuppressions (locale)})
const createMutation = useMutation ({
mutationFn: async () =>
@@ -71,17 +59,17 @@ const MaterialSyncSuppressionsPage: FC = () => {
sourceUri,
drivePath,
driveFileId,
reason}),
reason}, locale),
onSuccess: async () => {
await qc.invalidateQueries ({ queryKey: materialsKeys.suppressions () })
await qc.invalidateQueries ({ queryKey: materialsKeys.suppressions (locale) })
await qc.invalidateQueries ({ queryKey: materialsKeys.root })
setSourceUri ('')
setDrivePath ('')
setDriveFileId ('')
toast ({ title: '同期元抑止を登録しました' })
toast ({ title: label ('suppressions.registered') })
},
onError: () => {
toast ({ title: '同期元抑止の登録に失敗しました' })
toast ({ title: label ('suppressions.registerFailed') })
}})
const handleSubmit = (event: FormEvent) => {
@@ -93,12 +81,10 @@ const MaterialSyncSuppressionsPage: FC = () => {
return (
<MainArea>
<Helmet>
<title>{`素材同期抑止 | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('suppressions.title')}/>
<div className="space-y-5">
<PageTitle>同期抑止</PageTitle>
<PageTitle>{label ('suppressions.heading')}</PageTitle>
<form
onSubmit={handleSubmit}
@@ -106,21 +92,21 @@ const MaterialSyncSuppressionsPage: FC = () => {
text-stone-900 dark:border-stone-700 dark:bg-stone-900
dark:text-stone-100">
<div className="space-y-3">
<FormField label="同期元種別">
<FormField label={label ('suppressions.sourceKind')}>
{({ invalid }) => (
<select
value={sourceKind}
onChange={e => setSourceKind (
e.target.value as MaterialSyncSuppressionSourceKind)}
className={inputClass (invalid)}>
{Object.entries (SOURCE_KIND_LABELS).map (([value, label]) => (
<option key={value} value={value}>
{label}
{Object.entries (SOURCE_KIND_LABELS).map (([value, key]) => (
<option key={value} value={value} lang={message (key).language}>
{message (key).text}
</option>))}
</select>)}
</FormField>
<FormField label="同期元 URI">
<FormField label={label ('suppressions.sourceUri')}>
{({ invalid }) => (
<input
type="text"
@@ -129,7 +115,7 @@ const MaterialSyncSuppressionsPage: FC = () => {
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="Google Drive パス">
<FormField label={label ('suppressions.drivePath')}>
{({ invalid }) => (
<input
type="text"
@@ -138,7 +124,7 @@ const MaterialSyncSuppressionsPage: FC = () => {
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="Google Drive ファイル Id.">
<FormField label={label ('suppressions.driveFileId')}>
{({ invalid }) => (
<input
type="text"
@@ -147,15 +133,16 @@ const MaterialSyncSuppressionsPage: FC = () => {
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="事由">
<FormField label={label ('suppressions.reason')}>
{({ invalid }) => (
<select
value={reason}
onChange={e => setReason (e.target.value as MaterialSyncSuppressionReason)}
className={inputClass (invalid)}>
{REASONS.map (value => (
<option key={value} value={value}>
{REASON_NAMES[value]}
<option key={value} value={value}
lang={message (`suppressions.reasons.${value}`).language}>
{message (`suppressions.reasons.${value}`).text}
</option>))}
</select>)}
</FormField>
@@ -166,14 +153,14 @@ const MaterialSyncSuppressionsPage: FC = () => {
className="mt-4 rounded bg-blue-600 px-4 py-2 text-white
disabled:bg-gray-400"
disabled={createMutation.isPending}>
登録
{label ('suppressions.register')}
</Button>
</form>
{isLoading && <p>Loading...</p>}
{isLoading && <p>{label ('common:loading')}</p>}
{isError && (
<p className="text-red-600 dark:text-red-300">
同期元抑止一覧の取得に失敗しました.
{label ('suppressions.error')}
</p>)}
<div className="space-y-3">
@@ -184,14 +171,20 @@ const MaterialSyncSuppressionsPage: FC = () => {
text-stone-900 shadow-sm dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100">
<div className="font-medium">
{SOURCE_KIND_LABELS[suppression.sourceKind]}
{label (SOURCE_KIND_LABELS[suppression.sourceKind])}
</div>
<div className="break-all text-sm text-stone-700 dark:text-stone-300">
{suppression.normalizedSourceKey}
</div>
<div className="mt-2 text-sm text-stone-600 dark:text-stone-400">
事由: {reasonName (suppression.reason)} /
登録: {dateString (suppression.createdAt)}
{label ('suppressions.reasonPrefix')}
{REASONS.includes (suppression.reason as MaterialSyncSuppressionReason)
? label (`suppressions.reasons.${suppression.reason}`)
: suppression.reason} /{' '}
{label ('suppressions.registeredPrefix')}
<time dateTime={suppression.createdAt} lang={locale}>
{localisedDateString (suppression.createdAt, locale)}
</time>
</div>
</article>))}
</div>