このコミットが含まれているのは:
2026-10-18 12:18:52 +09:00
コミット a7e350d28e
51個のファイルの変更、626行の追加、321行の削除
+10 -8
ファイルの表示
@@ -3,9 +3,10 @@ import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer'
import TwitterEmbed from '@/components/TwitterEmbed'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import useDialogue from '@/lib/dialogues/useDialogue'
import { Button } from '@/components/ui/button'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, RefObject } from 'react'
@@ -36,6 +37,7 @@ const PostEmbed: FC<Props> = (
onError },
) => {
const dialogue = useDialogue ()
const { label } = useUITranslation ('posts')
const behaviourSettings = useClientBehaviourSettings ()
const embedAutoLoadMode = behaviourSettings.embedAutoLoad ?? 'auto'
const [manualLoadRequested, setManualLoadRequested] = useState (false)
@@ -135,7 +137,7 @@ const PostEmbed: FC<Props> = (
const externalLink = (
<a href={post.url} target="_blank" rel="noreferrer">
外部ページを開く
{label ('embed.openExternal')}
</a>)
const manualLoadControl = (
@@ -144,7 +146,7 @@ const PostEmbed: FC<Props> = (
type="button"
variant="outline"
onClick={() => setManualLoadRequested (true)}>
埋め込みを読込
{label ('embed.load')}
</Button>
{externalLink}
</div>)
@@ -239,15 +241,15 @@ const PostEmbed: FC<Props> = (
e.preventDefault ()
setFramed (await dialogue.confirm ({
title: '未確認の外部ページを表示します。',
title: label ('embed.confirmTitle'),
description: (
<div>
<p>悪意のあるスクリプトが実行される可能性があります。</p>
<p>表示しますか?</p>
<p>{label ('embed.risk')}</p>
<p>{label ('embed.question')}</p>
</div>),
confirmText: '表示' }))
confirmText: label ('shared:actions.show') }))
}}>
外部ページを表示
{label ('embed.showExternal')}
</a>)}
</div>)}
</>)
+2 -2
ファイルの表示
@@ -9,7 +9,7 @@ import FormField from '@/components/common/FormField'
import TextArea from '@/components/common/TextArea'
import { apiGet } from '@/lib/api'
import type { ComponentPropsWithoutRef, FC, SyntheticEvent } from 'react'
import type { ComponentPropsWithoutRef, FC, ReactNode, SyntheticEvent } from 'react'
import type { Tag } from '@/types'
@@ -36,7 +36,7 @@ const replaceToken = (value: string, start: number, end: number, text: string) =
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & {
tags: string
setTags: (tags: string) => void
errors?: string[] }
errors?: ReactNode[] }
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
+2 -2
ファイルの表示
@@ -3,7 +3,7 @@ import FormField from '@/components/common/FormField'
import { Button } from '@/components/ui/button'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react'
import type { FC, ReactNode } from 'react'
type Props = {
disabled?: boolean
@@ -11,7 +11,7 @@ type Props = {
setOriginalCreatedFrom: (x: string | null) => void
originalCreatedBefore: string | null
setOriginalCreatedBefore: (x: string | null) => void
errors?: string[] }
errors?: ReactNode[] }
const PostOriginalCreatedTimeField: FC<Props> = (
+11 -3
ファイルの表示
@@ -1,6 +1,10 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { create } from 'zustand'
import LocalisedText from '@/components/LocalisedText'
import { translateUIMessage } from '@/lib/i18n'
import type { FC } from 'react'
type OverlayStore = {
@@ -14,6 +18,9 @@ export const useOverlayStore = create<OverlayStore> (set => ({
const RouteBlockerOverlay: FC = () => {
const { i18n } = useTranslation ('common')
const loading = translateUIMessage ('loading', 'common', i18n.language)
const loadingLabel = translateUIMessage ('loadingLabel', 'common', i18n.language)
const active = useOverlayStore (s => s.active)
useEffect (() => {
@@ -35,14 +42,15 @@ const RouteBlockerOverlay: FC = () => {
return (
<div
role="progressbar"
aria-label="Loading"
aria-label={loadingLabel.text}
lang={loadingLabel.language}
className="fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm pointer-events-auto">
<div className="absolute inset-0 flex items-center justify-center">
<div className="rounded-2xl bg-black/60 text-white px-6 py-3 text-sm">
Loading...
<LocalisedText language={loading.language}>{loading.text}</LocalisedText>
</div>
</div>
</div>)
}
export default RouteBlockerOverlay
export default RouteBlockerOverlay
+31 -18
ファイルの表示
@@ -26,7 +26,9 @@ import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { dateString, originalCreatedAtString } from '@/lib/utils'
import { localisedDateString, originalCreatedAtString } from '@/lib/utils'
import LocalisedText from '@/components/LocalisedText'
import { useUITranslation } from '@/lib/useUITranslation'
import type { LocaleCode } from '@/lib/locales'
import type { CollisionDetection, DragEndEvent } from '@dnd-kit/core'
@@ -232,6 +234,7 @@ type ActiveTagDrag = {
const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
const locale = useLocale ()
const { label } = useUITranslation ('tags')
sp = Boolean (sp)
const qc = useQueryClient ()
@@ -325,8 +328,9 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
await reloadTags ()
toast ({
title: '上位タグ対応追加',
description: `《${ child?.name }》を《${ parent?.name }》の子タグに設定しました.` })
title: label ('relations.added'),
description: label ('relations.addedDescription', {
child: child.name, parent: parent.name }) })
break
}
@@ -347,11 +351,12 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
await reloadTags ()
toast ({
title: '上位タグ対応解除',
title: label ('relations.removed'),
description: (
fromParent
? `《${ child.name }》を《${ fromParent.name }》の子タグから外しました.`
: `《${ child.name }》のカテゴリを変更しました.`) })
? label ('relations.removedDescription', {
child: child.name, parent: fromParent.name })
: label ('relations.categoryChanged', { name: child.name })) })
break
}
@@ -359,7 +364,8 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
}
catch
{
toast ({ title: '上位タグ対応失敗', description: '独裁者である必要があります.' })
toast ({ title: label ('relations.failed'),
description: label ('relations.permissionRequired') })
}
finally
{
@@ -473,21 +479,26 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
? undefined
: `post-info-${ sp }`}
transition={{ layout: layoutTransition }}>
<SectionTitle>情報</SectionTitle>
<SectionTitle>{label ('shared:labels.information')}</SectionTitle>
<ul>
<li>Id.: {post.id}</li>
<li>{label ('shared:idPrefix')}{post.id}</li>
<li>
<>耕作者: </>
{label ('shared:labels.uploadedBy')}
{post.uploadedUser
? (
<PrefetchLink to={`/users/${ post.uploadedUser.id }`}>
{post.uploadedUser.name || '名もなきニジラー'}
{post.uploadedUser.name || label ('shared:labels.anonymous')}
</PrefetchLink>)
: 'bot操作'}
: label ('shared:labels.bot')}
</li>
<li>耕作日時: {dateString (post.createdAt)}</li>
<li>
<>リンク: </>
{label ('shared:labels.uploadedAt')}
<LocalisedText language={locale}>
{localisedDateString (post.createdAt, locale)}
</LocalisedText>
</li>
<li>
{label ('shared:labels.link')}
<a
className="break-all"
href={post.url}
@@ -497,13 +508,15 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
</a>
</li>
<li>
<>オリジナルの投稿日時: </>
{originalCreatedAtString (post.originalCreatedFrom,
post.originalCreatedBefore)}
{label ('shared:labels.originalPostedAt')}
<LocalisedText language={locale}>
{originalCreatedAtString (post.originalCreatedFrom,
post.originalCreatedBefore, locale)}
</LocalisedText>
</li>
<li>
<PrefetchLink to={`/posts/changes?id=${ post.id }`}>
履歴
{label ('navigation:history')}
</PrefetchLink>
</li>
</ul>
+5 -2
ファイルの表示
@@ -126,7 +126,7 @@ export const menuOutline = (
const TopNav: FC<Props> = ({ user, locales }) => {
const { label } = useUITranslation ('shared')
const { message, label } = useUITranslation ('shared')
const locale = useLocale ()
const localeName = locales.find (entry => entry.code === locale)?.name
const navigate = useLocaleNavigate ()
@@ -177,6 +177,7 @@ const TopNav: FC<Props> = ({ user, locales }) => {
width: 0,
visible: false })
const [menuOpen, setMenuOpen] = useState (false)
const menuToggleLabel = message (menuOpen ? 'dialogue.close' : 'navigation:menuToggle')
const [desktop, setDesktop] = useState (false)
const [density, setDensity] = useState<NavDensity> ('auto')
const [moreVsbl, setMoreVsbl] = useState (false)
@@ -474,10 +475,12 @@ const TopNav: FC<Props> = ({ user, locales }) => {
type="button"
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
border-0 bg-transparent pr-4"
aria-label={menuToggleLabel.text}
lang={menuToggleLabel.language}
onClick={() => {
setMenuOpen (!(menuOpen))
}}>
{menuOpen ? '×' : 'Menu'}
{menuOpen ? '×' : label ('navigation:menuToggle')}
</button>
</div>
</nav>
+2 -2
ファイルの表示
@@ -1,7 +1,7 @@
import type { FC } from 'react'
import type { FC, ReactNode } from 'react'
type Props = { id?: string
messages?: string[] }
messages?: ReactNode[] }
export const FieldWarning: FC<Props> = ({ id, messages }: Props) => {
+1 -1
ファイルの表示
@@ -16,7 +16,7 @@ type Props = {
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void }
className?: string
label: ReactNode
messages?: string[] }
messages?: ReactNode[] }
const FormField: FC<Props> = ({ children, checkBox, className, label, messages }: Props) => {
+13 -11
ファイルの表示
@@ -8,6 +8,7 @@ import { Dialog,
DialogHeader,
DialogTitle } from '@/components/ui/dialog'
import { DialogueContext } from '@/lib/dialogues/useDialogue'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, ReactNode } from 'react'
import type { AlertOptions,
@@ -42,6 +43,7 @@ type Props = { children: ReactNode }
const DialogueProvider: FC<Props> = ({ children }) => {
const { label } = useUITranslation ('shared')
const [queue, setQueue] = useState<DialogueRequest[]> ([])
const [pendingIds, setPendingIds] = useState<number[]> ([])
const [formActions, setFormActions] = useState<Record<number, DialogueFormAction[]>> ({ })
@@ -220,9 +222,9 @@ const DialogueProvider: FC<Props> = ({ children }) => {
className="shrink-0 flex-col gap-2 pt-4
md:flex-row md:justify-between md:gap-0 md:space-x-0">
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
{startActions.map (action => (
{startActions.map ((action, index) => (
<Button
key={action.label}
key={index}
className="w-full md:w-auto"
variant={action.variant === 'danger'
? 'destructive'
@@ -242,12 +244,12 @@ const DialogueProvider: FC<Props> = ({ children }) => {
onClick={() => closeRequest (active.id)}
disabled={pendingIds.includes (active.id)
|| nestedConfirm?.parentId === active.id}>
{active.options.cancelText ?? '取消'}
{active.options.cancelText ?? label ('actions.cancel')}
</Button>
{endActions.map (action => (
{endActions.map ((action, index) => (
<Button
key={action.label}
key={index}
className="w-full md:w-auto"
variant={action.variant === 'danger'
? 'destructive'
@@ -278,7 +280,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
<Button
variant="outline"
onClick={() => closeRequest (active.id, false)}>
{active.options.cancelText ?? '取消'}
{active.options.cancelText ?? label ('actions.cancel')}
</Button>
<Button
@@ -286,13 +288,13 @@ const DialogueProvider: FC<Props> = ({ children }) => {
? 'destructive'
: 'default'}
onClick={() => closeRequest (active.id, true)}>
{active.options.confirmText ?? '確定'}
{active.options.confirmText ?? label ('dialogue.confirm')}
</Button>
</>)}
{active.kind === 'alert' && (
<Button onClick={() => closeRequest (active.id)}>
{active.options.okText ?? '確定'}
{active.options.okText ?? label ('dialogue.confirm')}
</Button>)}
{active.kind === 'choice' && (
@@ -300,7 +302,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
<Button
variant="outline"
onClick={() => closeRequest (active.id, null)}>
{active.options.cancelText ?? '取消'}
{active.options.cancelText ?? label ('actions.cancel')}
</Button>
{active.options.choices.map (choice => (
@@ -340,7 +342,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
<Button
variant="outline"
onClick={() => closeNestedConfirm (false)}>
{nestedConfirm.options.cancelText ?? '取消'}
{nestedConfirm.options.cancelText ?? label ('actions.cancel')}
</Button>
<Button
@@ -348,7 +350,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
? 'destructive'
: 'default'}
onClick={() => closeNestedConfirm (true)}>
{nestedConfirm.options.confirmText ?? '確定'}
{nestedConfirm.options.confirmText ?? label ('dialogue.confirm')}
</Button>
</DialogFooter>
</DialogContent>
+4 -1
ファイルの表示
@@ -4,6 +4,7 @@ import { Helmet } from 'react-helmet-async'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
@@ -70,6 +71,7 @@ const SidebarComponent: FC<Props> = ({
sidebarKey,
side = 'left',
}) => {
const { message } = useUITranslation ('shared')
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
@@ -341,7 +343,8 @@ const SidebarComponent: FC<Props> = ({
<div
role="separator"
aria-orientation="vertical"
aria-label="サイドバー幅"
aria-label={message ('sidebarWidth').text}
lang={message ('sidebarWidth').language}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
+2 -2
ファイルの表示
@@ -5,7 +5,7 @@ import TranslatedText from '@/components/TranslatedText'
import type { FC, ReactNode } from 'react'
type TextMessages = string[] | undefined
type TextMessages = ReactNode[] | undefined
type CoreField = {
value: string
@@ -43,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 ?? []))]
+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 -2
ファイルの表示
@@ -1,12 +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 }
+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> = (
+2 -2
ファイルの表示
@@ -8,8 +8,8 @@ type Props = {
label: ReactNode
value: string
onChange: (value: string) => void
warnings?: string[]
errors?: string[]
warnings?: ReactNode[]
errors?: ReactNode[]
disabled?: boolean
type?: string
placeholder?: string
+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>
+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
+39 -29
ファイルの表示
@@ -3,7 +3,10 @@
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import LocalisedText from '@/components/LocalisedText'
import { translateUIMessage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
@@ -34,36 +37,43 @@ const DialogContent = React.forwardRef<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
& { closeLabel?: React.ReactNode }
>(
({ className, children, closeLabel = '閉ぢる', ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn (
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg',
'translate-x-[-50%] translate-y-[-50%]',
'gap-5 rounded-2xl border border-border',
'bg-background p-6 text-foreground shadow-2xl',
'duration-200',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className)}
{...props}>
{children}
<DialogPrimitive.Close
({ className, children, closeLabel, ...props }, ref) => {
const { i18n } = useTranslation ('shared')
const close = translateUIMessage ('dialogue.close', 'shared', i18n.language)
return (
<DialogPortal>
<DialogOverlay/>
<DialogPrimitive.Content
ref={ref}
className={cn (
'absolute left-4 top-4 rounded-full p-1',
'text-slate-500 transition-colors',
'hover:bg-slate-200 hover:text-slate-900',
'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50',
'focus:outline-none focus:ring-2 focus:ring-slate-400')}>
<X className="h-4 w-4"/>
<span className="sr-only">{closeLabel}</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>))
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg',
'translate-x-[-50%] translate-y-[-50%]',
'gap-5 rounded-2xl border border-border',
'bg-background p-6 text-foreground shadow-2xl',
'duration-200',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className)}
{...props}>
{children}
<DialogPrimitive.Close
className={cn (
'absolute left-4 top-4 rounded-full p-1',
'text-slate-500 transition-colors',
'hover:bg-slate-200 hover:text-slate-900',
'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50',
'focus:outline-none focus:ring-2 focus:ring-slate-400')}>
<X className="h-4 w-4"/>
<span className="sr-only">
{closeLabel ?? (
<LocalisedText language={close.language}>{close.text}</LocalisedText>)}
</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>)
})
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = (