アイヌ語追加 (#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行の削除
+5 -4
ファイルの表示
@@ -1,10 +1,11 @@
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField'
import TranslatedText from '@/components/TranslatedText'
import type { FC, ReactNode } from 'react'
type TextMessages = string[] | undefined
type TextMessages = ReactNode[] | undefined
type CoreField = {
value: string
@@ -42,7 +43,7 @@ type PostCoreDataFieldsProps = {
rows?: number }
parentPostIds: CoreField }
const groupedMessages = (...values: (TextMessages | null | undefined)[]): string[] =>
const groupedMessages = (...values: (TextMessages | null | undefined)[]): ReactNode[] =>
[...new Set (values.flatMap (value => value ?? []))]
@@ -54,7 +55,7 @@ const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
) => (
<>
<PostTextField
label="タイトル"
label={<TranslatedText namespace="shared" messageKey="labels.title"/>}
value={title.value}
disabled={title.disabled}
warnings={title.warnings}
@@ -82,7 +83,7 @@ const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
rows={tags.rows}/>
<PostTextField
label="親投稿"
label={<TranslatedText namespace="shared" messageKey="labels.parentPosts"/>}
value={parentPostIds.value}
disabled={parentPostIds.disabled}
warnings={parentPostIds.warnings}
+3 -2
ファイルの表示
@@ -1,11 +1,12 @@
import PostCoreDataFields from '@/components/posts/PostCoreDataFields'
import PostTextField from '@/components/posts/PostTextField'
import TranslatedText from '@/components/TranslatedText'
import type { FC, ReactNode } from 'react'
import type { PostCoreDataFieldsProps } from '@/components/posts/PostCoreDataFields'
type TextMessages = string[] | undefined
type TextMessages = ReactNode[] | undefined
type Props = {
url: {
@@ -29,7 +30,7 @@ const PostCreationDataFields: FC<Props> = (
) => (
<>
<PostTextField
label="URL"
label={<TranslatedText namespace="shared" messageKey="url"/>}
type={url.type}
value={url.value}
disabled={url.disabled}
+2 -1
ファイルの表示
@@ -1,7 +1,8 @@
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders as render } from '@/test/render'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
+5 -5
ファイルの表示
@@ -1,11 +1,12 @@
import PostTextField from '@/components/posts/PostTextField'
import TranslatedText from '@/components/TranslatedText'
import type { FC } from 'react'
import type { FC, ReactNode } from 'react'
type Props = {
value: string
onChange: (value: string) => void
errors?: string[]
errors?: ReactNode[]
disabled?: boolean }
@@ -16,12 +17,11 @@ const PostDurationField: FC<Props> = (
disabled },
) => (
<PostTextField
label="動画時間"
label={<TranslatedText namespace="shared" messageKey="labels.duration"/>}
value={value}
onChange={onChange}
errors={errors}
disabled={disabled}
type="text"/>
)
type="text"/>)
export default PostDurationField
+3 -3
ファイルの表示
@@ -1,13 +1,13 @@
import PostFormTagsArea from '@/components/PostFormTagsArea'
import FieldWarning from '@/components/common/FieldWarning'
import type { ComponentPropsWithoutRef, FC } from 'react'
import type { ComponentPropsWithoutRef, FC, ReactNode } from 'react'
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange'> & {
tags: string
setTags: (tags: string) => void
warnings?: string[]
errors?: string[] }
warnings?: ReactNode[]
errors?: ReactNode[] }
const PostTagsField: FC<Props> = (
+3 -3
ファイルの表示
@@ -5,11 +5,11 @@ import { inputClass } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
type Props = {
label: string
label: ReactNode
value: string
onChange: (value: string) => void
warnings?: string[]
errors?: string[]
warnings?: ReactNode[]
errors?: ReactNode[]
disabled?: boolean
type?: string
placeholder?: string
+2 -1
ファイルの表示
@@ -1,7 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import { renderWithProviders as render } from '@/test/render'
describe ('PostThumbnailPreview', () => {
it ('keeps an existing blob preview URL unchanged for normal post forms', () => {
+4 -2
ファイルの表示
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react'
@@ -15,10 +16,11 @@ type Props = {
const PostThumbnailPreview: FC<Props> = (
{ url,
file,
alt = 'サムネール',
alt,
className = 'h-16 w-16',
referrerPolicy },
) => {
const { message } = useUITranslation ('shared')
const [failed, setFailed] = useState (false)
const [fileUrl, setFileUrl] = useState<string | null> (null)
@@ -55,7 +57,7 @@ const PostThumbnailPreview: FC<Props> = (
return (
<img
src={resolvedUrl}
alt={alt}
alt={alt ?? message ('labels.thumbnail').text}
referrerPolicy={referrerPolicy}
className={cn (className, 'rounded border border-border object-cover')}
onError={() => setFailed (true)}/>)
+12 -8
ファイルの表示
@@ -1,8 +1,10 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import { act, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders as render } from '@/test/render'
import { textContentOf } from '@/test/text'
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
@@ -37,7 +39,7 @@ describe ('PostImportRowForm', () => {
const titleInput = screen.getByDisplayValue ('manual title')
await waitFor (() => expect (actions.at (-1)?.length).toBe (2))
const reset = actions.at (-1)?.find (action => action.label === '変更をリセット')
const reset = actions.at (-1)?.find (action => textContentOf (action.label) === '変更をリセット')
expect (reset).toMatchObject ({ placement: 'start', variant: 'danger', disabled: false })
await act (async () => {
await reset?.onSelect ()
@@ -47,7 +49,7 @@ describe ('PostImportRowForm', () => {
expect (titleInput).toHaveValue ('')
expect (onSave).not.toHaveBeenCalled ()
const save = actions.at (-1)?.find (action => action.label === '編輯内容を保存')
const save = actions.at (-1)?.find (action => textContentOf (action.label) === '編輯内容を保存')
await act (async () => {
await save?.onSelect ()
})
@@ -76,7 +78,7 @@ describe ('PostImportRowForm', () => {
await waitFor (() => expect (actions.length).toBe (2))
await act (async () => {
await actions.find (action => action.label === '変更をリセット')?.onSelect ()
await actions.find (action => textContentOf (action.label) === '変更をリセット')?.onSelect ()
})
expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument ()
@@ -124,7 +126,7 @@ describe ('PostImportRowForm', () => {
await waitFor (() => expect (actions.length).toBe (2))
await act (async () => {
void actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
void actions.find (action => textContentOf (action.label) === '編輯内容を保存')?.onSelect ()
})
expect (onSave).toHaveBeenCalledWith ({
@@ -171,7 +173,7 @@ describe ('PostImportRowForm', () => {
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
await act (async () => {
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
await actions.find (action => textContentOf (action.label) === '編輯内容を保存')?.onSelect ()
})
expect (onSave).toHaveBeenCalledWith ({
@@ -218,7 +220,8 @@ describe ('PostImportRowForm', () => {
onSave={vi.fn ()}/>)
await waitFor (() => expect (actions.length).toBe (2))
expect (actions.find (action => action.label === '変更をリセット')?.disabled).toBe (false)
const reset = actions.find (action => textContentOf (action.label) === '変更をリセット')
expect (reset?.disabled).toBe (false)
})
it (
@@ -249,7 +252,8 @@ describe ('PostImportRowForm', () => {
let savePromise: Promise<boolean | void> | undefined
await act (async () => {
savePromise = actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
const save = actions.find (action => textContentOf (action.label) === '編輯内容を保存')
savePromise = save?.onSelect ()
})
await waitFor (() => {
+24 -19
ファイルの表示
@@ -7,8 +7,11 @@ import PostDurationField from '@/components/posts/PostDurationField'
import PostTextField from '@/components/posts/PostTextField'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import { hasThumbnailBaseValue, hasVideoTag } from '@/lib/postImportRows'
import { renderPostImportMessage } from '@/components/posts/import/postImportRowStatus'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react'
import type { FC, ReactNode } from 'react'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportTypes'
@@ -47,9 +50,6 @@ const buildResetDraft = (row: PostImportRow): Draft => ({
duration: String (row.resetSnapshot.attributes.duration ?? ''),
thumbnailFile: undefined })
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
[...new Set (values.flatMap (value => value ?? []))]
const sameDraft = (left: Draft, right: Draft): boolean =>
left.url === right.url
&& left.title === right.title
@@ -98,6 +98,11 @@ const PostImportRowForm: FC<Props> = (
controls,
onSave },
) => {
const locale = useLocale ()
const { label } = useUITranslation ('imports')
const groupedMessages = (...values: (string[] | undefined)[]): ReactNode[] =>
[...new Set (values.flatMap (value => value ?? []))]
.map (value => renderPostImportMessage (value, locale))
const [draft, setDraft] = useState<Draft> (() => buildDraft (row))
const [messageRow, setMessageRow] = useState<PostImportRow | null> (null)
const [saving, setSaving] = useState (false)
@@ -144,9 +149,9 @@ const PostImportRowForm: FC<Props> = (
return false
const confirmed = await controls.confirm ({
title: '変更をリセットしますか?',
confirmText: 'リセット',
cancelText: '取消',
title: label ('shared:messages.resetChanges'),
confirmText: label ('shared:actions.reset'),
cancelText: label ('shared:actions.cancel'),
variant: 'danger' })
if (!(confirmed))
return false
@@ -156,7 +161,7 @@ const PostImportRowForm: FC<Props> = (
setMessageRow (null)
setCommittedThumbnailBase (resetDraft.thumbnailBase)
return false
}, [controls, resetDisabled, resetDraft])
}, [controls, resetDisabled, resetDraft, label])
const save = useCallback (async (): Promise<boolean> => {
setSaving (true)
@@ -178,16 +183,16 @@ const PostImportRowForm: FC<Props> = (
useEffect (() => {
controls.setActions ([{
label: '変更をリセット',
label: label ('review.reset'),
placement: 'start',
variant: 'danger',
disabled: resetDisabled,
onSelect: reset },
{
label: '編輯内容を保存',
label: label ('review.save'),
disabled: saving,
onSelect: save }])
}, [controls, resetDisabled, reset, save, saving])
}, [controls, resetDisabled, reset, save, saving, label])
return (
<>
@@ -210,17 +215,17 @@ const PostImportRowForm: FC<Props> = (
value: draft.url,
onChange: value => update ('url', value),
disabled: saving,
warnings: displayRow.fieldWarnings.url,
warnings: groupedMessages (displayRow.fieldWarnings.url),
errors: groupedMessages (
displayRow.validationErrors.url,
displayRow.importErrors?.url) }}
thumbnailField={
<>
<PostTextField
label="サムネール"
label={label ('shared:labels.thumbnail')}
value={draft.thumbnailBase}
disabled={saving}
warnings={currentThumbnailWarnings}
warnings={groupedMessages (currentThumbnailWarnings)}
errors={groupedMessages (
displayRow.validationErrors.thumbnailBase,
displayRow.importErrors?.thumbnailBase)}
@@ -244,7 +249,7 @@ const PostImportRowForm: FC<Props> = (
value: draft.title,
onChange: value => update ('title', value),
disabled: saving,
warnings: displayRow.fieldWarnings.title,
warnings: groupedMessages (displayRow.fieldWarnings.title),
errors: groupedMessages (
displayRow.validationErrors.title,
displayRow.importErrors?.title) },
@@ -270,7 +275,7 @@ const PostImportRowForm: FC<Props> = (
value: draft.tags,
onChange: value => update ('tags', value),
disabled: saving,
warnings: displayRow.fieldWarnings.tags,
warnings: groupedMessages (displayRow.fieldWarnings.tags),
errors: groupedMessages (
displayRow.validationErrors.tags,
displayRow.importErrors?.tags),
@@ -293,9 +298,9 @@ const PostImportRowForm: FC<Props> = (
displayRow.validationErrors.videoMs,
displayRow.importErrors?.videoMs)}/>)
: null}/>
<FieldWarning messages={displayRow.baseWarnings}/>
<FieldError messages={displayRow.validationErrors.base}/>
<FieldError messages={displayRow.importErrors?.base}/>
<FieldWarning messages={groupedMessages (displayRow.baseWarnings)}/>
<FieldError messages={groupedMessages (displayRow.validationErrors.base)}/>
<FieldError messages={groupedMessages (displayRow.importErrors?.base)}/>
</div>
</div>
</div>
+25 -16
ファイルの表示
@@ -3,7 +3,11 @@ import PostImportTagLinks from '@/components/posts/import/PostImportTagLinks'
import { Button } from '@/components/ui/button'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { displayPostImportStatus,
renderPostImportMessage } from '@/components/posts/import/postImportRowStatus'
import LocalisedText from '@/components/LocalisedText'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import {
canEditReviewRow,
canRetryResultRow,
@@ -14,6 +18,7 @@ import { cn, originalCreatedAtString } from '@/lib/utils'
import type { FC } from 'react'
import type { PostImportRow } from '@/lib/postImportTypes'
import type { LocaleCode } from '@/lib/locales'
type Props = {
row: PostImportRow
@@ -33,10 +38,10 @@ const summaryWarning = (row: PostImportRow): string | null =>
?? row.baseWarnings?.[0]
?? null
const summaryDate = (row: PostImportRow): string =>
const summaryDate = (row: PostImportRow, locale: LocaleCode): string =>
originalCreatedAtString (
row.attributes.originalCreatedFrom?.toString () ?? null,
row.attributes.originalCreatedBefore?.toString () ?? null)
row.attributes.originalCreatedBefore?.toString () ?? null, locale)
const PostImportRowSummary: FC<Props> = (
@@ -52,6 +57,10 @@ const PostImportRowSummary: FC<Props> = (
showActions = true,
showSkipToggle = false },
) => {
const locale = useLocale ()
const { label } = useUITranslation ('imports')
const localisedRowMessages = rowMessages?.map (value =>
renderPostImportMessage (value, locale))
const warning = summaryWarning (row)
const displayStatus = displayPostImportStatus (row)
const editVisible = onEdit != null
@@ -69,7 +78,7 @@ const PostImportRowSummary: FC<Props> = (
checked={skipChecked}
onChange={event => onToggleSkip?.(event.target.checked)}
disabled={skipDisabled === true}/>
<span>スキップ</span>
<span>{label ('shared:actions.skip')}</span>
</label>)
: null
@@ -96,17 +105,17 @@ const PostImportRowSummary: FC<Props> = (
</div>
<PostImportTagLinks tags={row.displayTags}/>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)}
<LocalisedText language={locale}>{summaryDate (row, locale)}</LocalisedText>
</div>
{showDuration && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
動画時間 {duration}
{label ('review.duration', { duration })}
</div>)}
{warning && (
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
{renderPostImportMessage (warning, locale)}
</div>)}
<FieldError messages={rowMessages}/>
<FieldError messages={localisedRowMessages}/>
</div>
<div className="space-y-1">
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
@@ -120,7 +129,7 @@ const PostImportRowSummary: FC<Props> = (
variant="outline"
onClick={onEdit}
disabled={editDisabled === true || !(editAllowed)}>
編輯
{label ('shared:actions.edit')}
</Button>)}
{showActions && retryAllowed && (
<Button
@@ -128,7 +137,7 @@ const PostImportRowSummary: FC<Props> = (
variant="outline"
onClick={onRetry}
disabled={retryDisabled === true}>
再試行
{label ('shared:actions.retry')}
</Button>)}
</div>
</div>
@@ -156,17 +165,17 @@ const PostImportRowSummary: FC<Props> = (
</div>
<PostImportTagLinks tags={row.displayTags}/>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)}
<LocalisedText language={locale}>{summaryDate (row, locale)}</LocalisedText>
</div>
{showDuration && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
動画時間 {duration}
{label ('review.duration', { duration })}
</div>)}
{warning && (
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
{renderPostImportMessage (warning, locale)}
</div>)}
<FieldError messages={rowMessages}/>
<FieldError messages={localisedRowMessages}/>
{skipControl}
</div>
</div>
@@ -179,7 +188,7 @@ const PostImportRowSummary: FC<Props> = (
variant="outline"
onClick={onEdit}
disabled={editDisabled === true || !(editAllowed)}>
編輯
{label ('shared:actions.edit')}
</Button>)}
{retryAllowed && (
<Button
@@ -188,7 +197,7 @@ const PostImportRowSummary: FC<Props> = (
variant="outline"
onClick={onRetry}
disabled={retryDisabled === true}>
再試行
{label ('shared:actions.retry')}
</Button>)}
</div>)}
</div>
+2 -9
ファイルの表示
@@ -1,4 +1,5 @@
import StatusBadge from '@/components/common/StatusBadge'
import TranslatedText from '@/components/TranslatedText'
import type { FC } from 'react'
@@ -8,14 +9,6 @@ import type { PostImportBadgeValue } from '@/components/posts/import/postImportR
type Props = {
value: PostImportBadgeValue }
const LABELS: Record<PostImportBadgeValue, string> = {
ready: '登録可能',
error: '登録不可',
warning: '警告',
skipped: 'スキップ',
created: '登録済み',
failed: '登録失敗' }
const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
ready: 'success',
error: 'warning',
@@ -27,7 +20,7 @@ const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
const PostImportStatusBadge: FC<Props> = ({ value }) => (
<StatusBadge tone={TONES[value]}>
{LABELS[value]}
<TranslatedText namespace="imports" messageKey={`status.${ value }`}/>
</StatusBadge>)
export default PostImportStatusBadge
+2 -1
ファイルの表示
@@ -1,7 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import { renderWithProviders as render } from '@/test/render'
describe ('PostImportThumbnailPreview', () => {
beforeEach (() => {
+1 -1
ファイルの表示
@@ -12,7 +12,7 @@ type Props = {
const PostImportThumbnailPreview: FC<Props> = (
{ url,
file,
alt = 'サムネール',
alt,
className = 'h-16 w-16' },
) => (
<PostThumbnailPreview
+41
ファイルの表示
@@ -1,3 +1,10 @@
import { createElement } from 'react'
import LocalisedText from '@/components/LocalisedText'
import { i18n, translateUIMessage } from '@/lib/i18n'
import type { ReactNode } from 'react'
import type { PostImportRow } from '@/lib/postImportTypes'
export type PostImportDisplayStatus =
@@ -10,6 +17,40 @@ export type PostImportDisplayStatus =
export type PostImportBadgeValue = PostImportDisplayStatus
const IMPORT_MESSAGE_KEYS = [
'validation.duplicateUrl', 'validation.processingFailed',
'validation.tooMany', 'validation.urlTooLong', 'validation.invalidUrl',
'validation.protocol', 'validation.duplicateRow', 'storage.readFailed',
'storage.writeFailed', 'storage.clearFailed', 'source.urlRequired',
'shared:labels.thumbnailMissing']
// Keep stored and compared messages intact; translate only recognised UI messages at display time.
export const renderPostImportMessage = (value: string, locale: string): ReactNode => {
for (const reference of IMPORT_MESSAGE_KEYS)
{
const [namespace, key] =
reference.includes (':')
? reference.split (':')
: ['imports', reference]
const template: unknown = i18n.getResource ('ja', namespace, key)
if (typeof template !== 'string')
continue
const names = [...template.matchAll (/\{\{(\w+)\}\}/g)].map (match => match[1])
const pattern = template.split (/(\{\{\w+\}\})/).map (part =>
part.startsWith ('{{')
? '(\\d+)'
: part.replace (/[.*+?^${}()|[\]\\]/g, '\\$&')).join ('')
const match = value.match (new RegExp (`^${pattern}$`))
if (match == null)
continue
const values = Object.fromEntries (names.map ((name, index) => [name, match[index + 1]]))
const result = translateUIMessage (key, namespace, locale, values)
return createElement (LocalisedText, { language: result.language, children: result.text })
}
return value
}
const hasWarnings = (row: PostImportRow): boolean =>
Object.values (row.fieldWarnings ?? { }).some (messages => messages.length > 0)
|| row.baseWarnings.length > 0