diff --git a/frontend/src/components/PostEmbed.tsx b/frontend/src/components/PostEmbed.tsx index 9933ada..a8e7108 100644 --- a/frontend/src/components/PostEmbed.tsx +++ b/frontend/src/components/PostEmbed.tsx @@ -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 = ( 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 = ( const externalLink = ( - 外部ページを開く + {label ('embed.openExternal')} ) const manualLoadControl = ( @@ -144,7 +146,7 @@ const PostEmbed: FC = ( type="button" variant="outline" onClick={() => setManualLoadRequested (true)}> - 埋め込みを読込 + {label ('embed.load')} {externalLink} ) @@ -239,15 +241,15 @@ const PostEmbed: FC = ( e.preventDefault () setFramed (await dialogue.confirm ({ - title: '未確認の外部ページを表示します。', + title: label ('embed.confirmTitle'), description: (
-

悪意のあるスクリプトが実行される可能性があります。

-

表示しますか?

+

{label ('embed.risk')}

+

{label ('embed.question')}

), - confirmText: '表示' })) + confirmText: label ('shared:actions.show') })) }}> - 外部ページを表示 + {label ('embed.showExternal')} )} )} ) diff --git a/frontend/src/components/PostFormTagsArea.tsx b/frontend/src/components/PostFormTagsArea.tsx index 60425a2..0256c95 100644 --- a/frontend/src/components/PostFormTagsArea.tsx +++ b/frontend/src/components/PostFormTagsArea.tsx @@ -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, 'value' | 'onChange' | 'onBlur'> & { tags: string setTags: (tags: string) => void - errors?: string[] } + errors?: ReactNode[] } const PostFormTagsArea: FC = ({ tags, setTags, errors, ...rest }) => { diff --git a/frontend/src/components/PostOriginalCreatedTimeField.tsx b/frontend/src/components/PostOriginalCreatedTimeField.tsx index bf7d5ee..a8b485b 100644 --- a/frontend/src/components/PostOriginalCreatedTimeField.tsx +++ b/frontend/src/components/PostOriginalCreatedTimeField.tsx @@ -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 = ( diff --git a/frontend/src/components/RouteBlockerOverlay.tsx b/frontend/src/components/RouteBlockerOverlay.tsx index bd46e57..83c936f 100644 --- a/frontend/src/components/RouteBlockerOverlay.tsx +++ b/frontend/src/components/RouteBlockerOverlay.tsx @@ -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 (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 (
- Loading... + {loading.text}
) } -export default RouteBlockerOverlay \ No newline at end of file +export default RouteBlockerOverlay diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index 49d1041..cf94c86 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -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 = ({ className, post, sp }) => { const locale = useLocale () + const { label } = useUITranslation ('tags') sp = Boolean (sp) const qc = useQueryClient () @@ -325,8 +328,9 @@ const TagDetailSidebar: FC = ({ 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 = ({ 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 = ({ className, post, sp }) => { } catch { - toast ({ title: '上位タグ対応失敗', description: '独裁者である必要があります.' }) + toast ({ title: label ('relations.failed'), + description: label ('relations.permissionRequired') }) } finally { @@ -473,21 +479,26 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => { ? undefined : `post-info-${ sp }`} transition={{ layout: layoutTransition }}> - 情報 + {label ('shared:labels.information')}
    -
  • Id.: {post.id}
  • +
  • {label ('shared:idPrefix')}{post.id}
  • - <>耕作者: + {label ('shared:labels.uploadedBy')} {post.uploadedUser ? ( - {post.uploadedUser.name || '名もなきニジラー'} + {post.uploadedUser.name || label ('shared:labels.anonymous')} ) - : 'bot操作'} + : label ('shared:labels.bot')}
  • -
  • 耕作日時: {dateString (post.createdAt)}
  • - <>リンク: + {label ('shared:labels.uploadedAt')} + + {localisedDateString (post.createdAt, locale)} + +
  • +
  • + {label ('shared:labels.link')} = ({ className, post, sp }) => {
  • - <>オリジナルの投稿日時: - {originalCreatedAtString (post.originalCreatedFrom, - post.originalCreatedBefore)} + {label ('shared:labels.originalPostedAt')} + + {originalCreatedAtString (post.originalCreatedFrom, + post.originalCreatedBefore, locale)} +
  • - 履歴 + {label ('navigation:history')}
diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 9ef49fc..bbada0c 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -126,7 +126,7 @@ export const menuOutline = ( const TopNav: FC = ({ 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 = ({ 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 ('auto') const [moreVsbl, setMoreVsbl] = useState (false) @@ -474,10 +475,12 @@ const TopNav: FC = ({ 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')} diff --git a/frontend/src/components/common/FieldWarning.tsx b/frontend/src/components/common/FieldWarning.tsx index 1f42ed0..52770f1 100644 --- a/frontend/src/components/common/FieldWarning.tsx +++ b/frontend/src/components/common/FieldWarning.tsx @@ -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 = ({ id, messages }: Props) => { diff --git a/frontend/src/components/common/FormField.tsx b/frontend/src/components/common/FormField.tsx index 67b1f65..0c89297 100644 --- a/frontend/src/components/common/FormField.tsx +++ b/frontend/src/components/common/FormField.tsx @@ -16,7 +16,7 @@ type Props = { onChange: (event: React.ChangeEvent) => void } className?: string label: ReactNode - messages?: string[] } + messages?: ReactNode[] } const FormField: FC = ({ children, checkBox, className, label, messages }: Props) => { diff --git a/frontend/src/components/dialogues/DialogueProvider.tsx b/frontend/src/components/dialogues/DialogueProvider.tsx index 36c27c1..f8e966f 100644 --- a/frontend/src/components/dialogues/DialogueProvider.tsx +++ b/frontend/src/components/dialogues/DialogueProvider.tsx @@ -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 = ({ children }) => { + const { label } = useUITranslation ('shared') const [queue, setQueue] = useState ([]) const [pendingIds, setPendingIds] = useState ([]) const [formActions, setFormActions] = useState> ({ }) @@ -220,9 +222,9 @@ const DialogueProvider: FC = ({ children }) => { className="shrink-0 flex-col gap-2 pt-4 md:flex-row md:justify-between md:gap-0 md:space-x-0">
- {startActions.map (action => ( + {startActions.map ((action, index) => ( - {endActions.map (action => ( + {endActions.map ((action, index) => ( )} {active.kind === 'alert' && ( )} {active.kind === 'choice' && ( @@ -300,7 +302,7 @@ const DialogueProvider: FC = ({ children }) => { {active.options.choices.map (choice => ( @@ -340,7 +342,7 @@ const DialogueProvider: FC = ({ children }) => { diff --git a/frontend/src/components/layout/SidebarComponent.tsx b/frontend/src/components/layout/SidebarComponent.tsx index 574f924..44cef66 100644 --- a/frontend/src/components/layout/SidebarComponent.tsx +++ b/frontend/src/components/layout/SidebarComponent.tsx @@ -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 = ({ 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 = ({
+const groupedMessages = (...values: (TextMessages | null | undefined)[]): ReactNode[] => [...new Set (values.flatMap (value => value ?? []))] diff --git a/frontend/src/components/posts/PostCreationDataFields.tsx b/frontend/src/components/posts/PostCreationDataFields.tsx index 7348c5d..37c34b8 100644 --- a/frontend/src/components/posts/PostCreationDataFields.tsx +++ b/frontend/src/components/posts/PostCreationDataFields.tsx @@ -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 = ( ) => ( <> } type={url.type} value={url.value} disabled={url.disabled} diff --git a/frontend/src/components/posts/PostDurationField.tsx b/frontend/src/components/posts/PostDurationField.tsx index 7d60233..ce3396a 100644 --- a/frontend/src/components/posts/PostDurationField.tsx +++ b/frontend/src/components/posts/PostDurationField.tsx @@ -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 } diff --git a/frontend/src/components/posts/PostTagsField.tsx b/frontend/src/components/posts/PostTagsField.tsx index 59d520a..e70e7d9 100644 --- a/frontend/src/components/posts/PostTagsField.tsx +++ b/frontend/src/components/posts/PostTagsField.tsx @@ -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, 'value' | 'onChange'> & { tags: string setTags: (tags: string) => void - warnings?: string[] - errors?: string[] } + warnings?: ReactNode[] + errors?: ReactNode[] } const PostTagsField: FC = ( diff --git a/frontend/src/components/posts/PostTextField.tsx b/frontend/src/components/posts/PostTextField.tsx index 2b5e628..750344c 100644 --- a/frontend/src/components/posts/PostTextField.tsx +++ b/frontend/src/components/posts/PostTextField.tsx @@ -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 diff --git a/frontend/src/components/posts/import/PostImportRowForm.tsx b/frontend/src/components/posts/import/PostImportRowForm.tsx index 641fe4a..d40e6dd 100644 --- a/frontend/src/components/posts/import/PostImportRowForm.tsx +++ b/frontend/src/components/posts/import/PostImportRowForm.tsx @@ -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 = ( 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 (() => buildDraft (row)) const [messageRow, setMessageRow] = useState (null) const [saving, setSaving] = useState (false) @@ -144,9 +149,9 @@ const PostImportRowForm: FC = ( 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 = ( setMessageRow (null) setCommittedThumbnailBase (resetDraft.thumbnailBase) return false - }, [controls, resetDisabled, resetDraft]) + }, [controls, resetDisabled, resetDraft, label]) const save = useCallback (async (): Promise => { setSaving (true) @@ -178,16 +183,16 @@ const PostImportRowForm: FC = ( 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 = ( 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={ <> = ( 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 = ( 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 = ( displayRow.validationErrors.videoMs, displayRow.importErrors?.videoMs)}/>) : null}/> - - - + + +
diff --git a/frontend/src/components/posts/import/PostImportRowSummary.tsx b/frontend/src/components/posts/import/PostImportRowSummary.tsx index 4cbfbf1..7b5cefa 100644 --- a/frontend/src/components/posts/import/PostImportRowSummary.tsx +++ b/frontend/src/components/posts/import/PostImportRowSummary.tsx @@ -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 = ( @@ -52,6 +57,10 @@ const PostImportRowSummary: FC = ( 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 = ( checked={skipChecked} onChange={event => onToggleSkip?.(event.target.checked)} disabled={skipDisabled === true}/> - スキップ + {label ('shared:actions.skip')} ) : null @@ -96,17 +105,17 @@ const PostImportRowSummary: FC = (
- {summaryDate (row)} + {summaryDate (row, locale)}
{showDuration && (
- 動画時間 {duration} + {label ('review.duration', { duration })}
)} {warning && (
- {warning} + {renderPostImportMessage (warning, locale)}
)} - +
{displayStatus != null && } @@ -120,7 +129,7 @@ const PostImportRowSummary: FC = ( variant="outline" onClick={onEdit} disabled={editDisabled === true || !(editAllowed)}> - 編輯 + {label ('shared:actions.edit')} )} {showActions && retryAllowed && ( )}
@@ -156,17 +165,17 @@ const PostImportRowSummary: FC = (
- {summaryDate (row)} + {summaryDate (row, locale)}
{showDuration && (
- 動画時間 {duration} + {label ('review.duration', { duration })}
)} {warning && (
- {warning} + {renderPostImportMessage (warning, locale)}
)} - + {skipControl} @@ -179,7 +188,7 @@ const PostImportRowSummary: FC = ( variant="outline" onClick={onEdit} disabled={editDisabled === true || !(editAllowed)}> - 編輯 + {label ('shared:actions.edit')} )} {retryAllowed && ( )} )} diff --git a/frontend/src/components/posts/import/postImportRowStatus.ts b/frontend/src/components/posts/import/postImportRowStatus.ts index 03b4eb9..a73a672 100644 --- a/frontend/src/components/posts/import/postImportRowStatus.ts +++ b/frontend/src/components/posts/import/postImportRowStatus.ts @@ -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 diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index b272234..1badb06 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -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 & { closeLabel?: React.ReactNode } >( - ({ className, children, closeLabel = '閉ぢる', ...props }, ref) => ( - - - - {children} - - { + const { i18n } = useTranslation ('shared') + const close = translateUIMessage ('dialogue.close', 'shared', i18n.language) + return ( + + + - - {closeLabel} - - - )) + '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} + + + + + {closeLabel ?? ( + {close.text})} + + + + ) + }) DialogContent.displayName = DialogPrimitive.Content.displayName const DialogHeader = ( diff --git a/frontend/src/lib/dialogues/useDialogue.ts b/frontend/src/lib/dialogues/useDialogue.ts index 9ab65d7..0817b54 100644 --- a/frontend/src/lib/dialogues/useDialogue.ts +++ b/frontend/src/lib/dialogues/useDialogue.ts @@ -26,7 +26,7 @@ type ChoiceOptions = { title: ReactNode closeLabel?: ReactNode } type DialogueFormAction = { - label: string + label: ReactNode placement?: 'start' | 'end' variant?: DialogueVariant disabled?: boolean @@ -37,10 +37,10 @@ type DialogueFormControls = { setActions: (actions: DialogueFormAction[]) => void confirm: (options: ConfirmOptions) => Promise } -type DialogueFormOptions = { title: string +type DialogueFormOptions = { title: ReactNode description?: ReactNode body: (controls: DialogueFormControls) => ReactNode - cancelText?: string + cancelText?: ReactNode size?: 'default' | 'large' } type DialogueAPI = diff --git a/frontend/src/lib/postImportSourceValidation.ts b/frontend/src/lib/postImportSourceValidation.ts index cc37ee7..8a4b18f 100644 --- a/frontend/src/lib/postImportSourceValidation.ts +++ b/frontend/src/lib/postImportSourceValidation.ts @@ -1,3 +1,5 @@ +import { translateUIMessage } from '@/lib/i18n' + import type { PostImportSourceIssue } from '@/lib/postImportTypes' const MAX_ROWS = 100 @@ -67,7 +69,8 @@ export const validateImportSource = ( { issues.push ({ sourceRow, - message: `取込件数は ${ MAX_ROWS } 件までです.`, + message: translateUIMessage ('validation.tooMany', 'imports', 'ja', { + count: MAX_ROWS }).text, url: displayUrl }) return } @@ -75,7 +78,7 @@ export const validateImportSource = ( { issues.push ({ sourceRow, - message: 'URL が長すぎます.', + message: translateUIMessage ('validation.urlTooLong', 'imports', 'ja').text, url: displayUrl }) return } @@ -84,7 +87,7 @@ export const validateImportSource = ( { issues.push ({ sourceRow, - message: 'URL の形式が不正です.', + message: translateUIMessage ('validation.invalidUrl', 'imports', 'ja').text, url: displayUrl }) return } @@ -92,7 +95,7 @@ export const validateImportSource = ( { issues.push ({ sourceRow, - message: 'HTTP または HTTPS の URL ではありません.', + message: translateUIMessage ('validation.protocol', 'imports', 'ja').text, url: displayUrl }) return } @@ -100,7 +103,7 @@ export const validateImportSource = ( { issues.push ({ sourceRow, - message: 'URL の形式が不正です.', + message: translateUIMessage ('validation.invalidUrl', 'imports', 'ja').text, url: displayUrl }) return } @@ -110,7 +113,8 @@ export const validateImportSource = ( { issues.push ({ sourceRow, - message: `${ duplicateRow } 行目と同じ URL です.`, + message: translateUIMessage ('validation.duplicateRow', 'imports', 'ja', { + row: duplicateRow }).text, url: displayUrl }) return } diff --git a/frontend/src/lib/postImportStorage.ts b/frontend/src/lib/postImportStorage.ts index ec29cad..c5ab2d5 100644 --- a/frontend/src/lib/postImportStorage.ts +++ b/frontend/src/lib/postImportStorage.ts @@ -1,3 +1,5 @@ +import { translateUIMessage } from '@/lib/i18n' + import type { StorageErrorHandler } from '@/lib/postImportTypes' const SOURCE_DRAFT_KEY = 'post-import-source-draft' @@ -16,7 +18,7 @@ const readStorage = ( } catch { - onError?.('保存済みデータを読み込めませんでした.') + onError?.(translateUIMessage ('storage.readFailed', 'imports', 'ja').text) return null } } @@ -37,7 +39,7 @@ const writeStorage = ( } catch { - onError?.('ブラウザへ保存できませんでした.') + onError?.(translateUIMessage ('storage.writeFailed', 'imports', 'ja').text) return false } } @@ -56,7 +58,7 @@ const removeStorage = ( } catch { - onError?.('保存済みデータを削除できませんでした.') + onError?.(translateUIMessage ('storage.clearFailed', 'imports', 'ja').text) } } diff --git a/frontend/src/lib/useUITranslation.ts b/frontend/src/lib/useUITranslation.ts index 1a36e5c..6751652 100644 --- a/frontend/src/lib/useUITranslation.ts +++ b/frontend/src/lib/useUITranslation.ts @@ -1,4 +1,4 @@ -import { createElement } from 'react' +import { createElement, useCallback } from 'react' import { useTranslation } from 'react-i18next' import LocalisedText from '@/components/LocalisedText' @@ -10,15 +10,15 @@ export const useUITranslation = (namespace = 'common') => { const locale = useLocale () useTranslation (namespace, { lng: locale }) - const message = (key: string, values?: Record) => { + const message = useCallback ((key: string, values?: Record) => { return translateUIMessage (key, namespace, locale, values) - } + }, [namespace, locale]) - const label = (key: string, values?: Record) => { + const label = useCallback ((key: string, values?: Record) => { const result = message (key, values) return createElement (LocalisedText, { language: result.language, children: result.text }) - } + }, [message]) return { message, label } } diff --git a/frontend/src/locales/ain-Kana/common.json b/frontend/src/locales/ain-Kana/common.json index eb291b1..5041cc1 100644 --- a/frontend/src/locales/ain-Kana/common.json +++ b/frontend/src/locales/ain-Kana/common.json @@ -1,4 +1,5 @@ -{ "countLabel": "チピㇱキㇷ゚", +{ "loadingLabel": "$t(common:loading)", + "countLabel": "チピㇱキㇷ゚", "categoryLabel": "イソネカ", "stateLabel": "シㇼキ", "search": "イフナラ", diff --git a/frontend/src/locales/ain-Kana/gekanator.json b/frontend/src/locales/ain-Kana/gekanator.json index f76116d..6afd3eb 100644 --- a/frontend/src/locales/ain-Kana/gekanator.json +++ b/frontend/src/locales/ain-Kana/gekanator.json @@ -1,8 +1,5 @@ -{ - "unavailable": { - "title": "Gekanator タネ シサㇺ イタㇰ アニ パテㇰ エシノッ エアㇱカイ!", - "back": "ホㇱキ ソㇱ ウン ホㇱピ", - "switchToJapanese": "シサㇺ イタㇰ ウン シンナ", - "imageAlt": "ポロンノ チㇱ コㇿ アン 洗澡鹿" - } -} +{ "unavailable": { + "title": "Gekanator タネ シサㇺ イタㇰ アニ パテㇰ エシノッ エアㇱカイ!", + "back": "ホㇱキ ソㇱ ウン ホㇱピ", + "switchToJapanese": "シサㇺ イタㇰ ウン シンナ", + "imageAlt": "ポロンノ チㇱ コㇿ アン シチャウカ ユㇰ ニチカ" } } diff --git a/frontend/src/locales/ain-Kana/history.json b/frontend/src/locales/ain-Kana/history.json index 60a4f31..b3e8ed2 100644 --- a/frontend/src/locales/ain-Kana/history.json +++ b/frontend/src/locales/ain-Kana/history.json @@ -1,4 +1,6 @@ -{ "postTitle": "$t(tags:history)", +{ "_untranslated": ["exportPath"], + "exportPath": "export_path", + "postTitle": "$t(tags:history)", "tagTitle": "タㇰ イヌイェㇷ゚ フㇱコアンペ", "materialTitle": "チカㇻペ フㇱコアンペ", "postPrefix": ":ミンタㇻ ", diff --git a/frontend/src/locales/ain-Kana/imports.json b/frontend/src/locales/ain-Kana/imports.json index 3156ef9..2df9ead 100644 --- a/frontend/src/locales/ain-Kana/imports.json +++ b/frontend/src/locales/ain-Kana/imports.json @@ -1,4 +1,26 @@ -{ "status": { +{ "_untranslated": ["source", "review", "validation", "storage"], + "source": { + "title": "広場に投稿を追加", "urls": "URL リスト(1 行 1 URL)", + "restoreFailed": "保存済み入力を復元できませんでした", "saveFailed": "入力内容を保存できませんでした", + "urlRequired": "URL を入力してください.", "rowPrefix": "{{row}} 行目: ", + "lineCount": "入力行数 {{count}} / {{limit}}", "next": "次へ" }, + "review": { + "title": "追加内容確認", "clearFailed": "入力内容を削除できませんでした", + "validateFailed": "行の再検証に失敗しました", "editPost": "投稿を編輯", + "incomplete": "登録結果が不完全でした", "retryFailed": "再試行に失敗しました", + "submitFailed": "登録に失敗しました", "existingSkipped": "既存投稿による自動スキップ {{count}}件", + "creatable": "作成対象 {{count}}件", "manualSkipped": "手動スキップ {{count}}件", + "pending": "未処理 {{count}}件", "back": "戻る", "addAll": "一括追加", + "reset": "変更をリセット", "save": "編輯内容を保存", "duration": "動画時間 {{duration}}" }, + "validation": { + "duplicateUrl": "URL が重複しています.", "processingFailed": "登録中にエラーが発生しました.", + "tooMany": "取込件数は {{count}} 件までです.", "urlTooLong": "URL が長すぎます.", + "invalidUrl": "URL の形式が不正です.", "protocol": "HTTP または HTTPS の URL ではありません.", + "duplicateRow": "{{row}} 行目と同じ URL です." }, + "storage": { + "readFailed": "保存済みデータを読み込めませんでした.", "writeFailed": "ブラウザへ保存できませんでした.", + "clearFailed": "保存済みデータを削除できませんでした." }, + "status": { "ready": "アオ エアㇱカイ", "error": "アオ エアイカㇷ゚", "warning": "イパㇰカㇱヌ", diff --git a/frontend/src/locales/ain-Kana/navigation.json b/frontend/src/locales/ain-Kana/navigation.json index 7b368f0..32dd29e 100644 --- a/frontend/src/locales/ain-Kana/navigation.json +++ b/frontend/src/locales/ain-Kana/navigation.json @@ -1,4 +1,5 @@ -{ "posts": "ミンタㇻ", +{ "menuToggle": "$t(shared:labels.menu)", + "posts": "ミンタㇻ", "postsWithCount": "ミンタㇻ({{count}})", "list": "オピッタ", "search": "イフナラ", diff --git a/frontend/src/locales/ain-Kana/posts.json b/frontend/src/locales/ain-Kana/posts.json index 3f9a727..a0bc77f 100644 --- a/frontend/src/locales/ain-Kana/posts.json +++ b/frontend/src/locales/ain-Kana/posts.json @@ -1,4 +1,23 @@ -{ "edit": { "validationFailed": "アシㇼ カㇻ カ エアイカㇷ゚……", +{ "_untranslated": ["list", "search", "detail", "embed"], + "list": { + "tagline": "{{brand}} 〜 ぼざろクリーチャーシリーズ綜合リンク集サイト", + "empty": "広場には何もありませんよ.", "viewWiki": "Wiki を見る", + "and": " and ", "or": " or " }, + "search": { + "title": "広場検索", "matchType": "検索区分:", "all": "AND", "any": "OR", + "createdAt": "投稿日時" }, + "detail": { + "networkFailed": "通信に失敗しました……", + "children": "この投稿には {{count}} 件の子投稿があります.", + "parent": "この投稿には 1 件の親投稿があります.", + "parentAndSiblings": "この投稿には 1 件の親投稿と {{count}} 件の姉妹投稿があります.", + "viewed": "閲覧済", "unviewed": "未閲覧", "emptyRelated": "まだないよ(笑)" }, + "embed": { + "openExternal": "外部ページを開く", "load": "埋め込みを読込", + "confirmTitle": "未確認の外部ページを表示します。", + "risk": "悪意のあるスクリプトが実行される可能性があります。", + "question": "表示しますか?", "showExternal": "外部ページを表示" }, + "edit": { "validationFailed": "アシㇼ カㇻ カ エアイカㇷ゚……", "conflictTitle": "モㇱマ クㇽ アシㇼ カㇻ ワ アン!", "conflictDescription": "モㇱマ イカㇻ クㇽ エエトコ アシㇼ カㇻ ワ アン。", "conflictQuestion": "タネ エカㇻ シンナ ペ ネコン エキ クス ネ ヤ?", diff --git a/frontend/src/locales/ain-Kana/shared.json b/frontend/src/locales/ain-Kana/shared.json index 4999d83..69a43fd 100644 --- a/frontend/src/locales/ain-Kana/shared.json +++ b/frontend/src/locales/ain-Kana/shared.json @@ -1,4 +1,8 @@ -{ "labels": { +{ "_untranslated": ["dialogue.confirm", "sidebarWidth", "results", "updateFailedMessage"], + "dialogue": { "confirm": "確定", "close": "$t(settings:actions.close)" }, + "sidebarWidth": "サイドバー幅", "url": "URL", "idPrefix": "Id.: ", "results": "検索結果", + "updateFailedMessage": "更新に失敗しました.", + "labels": { "title": "レ", "content": "オルㇱペ", "body": "ヌイェㇷ゚", diff --git a/frontend/src/locales/ain-Kana/tags.json b/frontend/src/locales/ain-Kana/tags.json index 5d9b774..9da2fed 100644 --- a/frontend/src/locales/ain-Kana/tags.json +++ b/frontend/src/locales/ain-Kana/tags.json @@ -1,4 +1,22 @@ -{ "title": "タㇰ", +{ "_untranslated": ["deprecated", "nico", "relations"], + "deprecated": "廃止済", + "nico": { + "discardConfirm": "編集中の内容を破棄しますか?", "discard": "破棄", + "updated": "連携を更新しました.", "updateFailed": "更新できませんでした.", + "loadFailed": "読込失敗", "loadFailedDescription": "ニコニコ連携を読み込めませんでした.", + "description": "ニコニコタグを広場のタグへ結び付けます.", + "linkedTags": "連携タグ", "linkStatus": "連携状態", + "all": "すべて", "linked": "連携済み", "unlinked": "未連携", + "firstRecorded": "最初に記載された日時", "recentRecorded": "最近記載された日時", + "edit": "編集", "tagsToLink": "連携する広場タグ", + "tagsPlaceholder": "タグ名を空白または改行で区切って入力" }, + "relations": { + "added": "上位タグ対応追加", "removed": "上位タグ対応解除", "failed": "上位タグ対応失敗", + "addedDescription": "《{{child}}》を《{{parent}}》の子タグに設定しました.", + "removedDescription": "《{{child}}》を《{{parent}}》の子タグから外しました.", + "categoryChanged": "《{{name}}》のカテゴリを変更しました.", + "permissionRequired": "独裁者である必要があります." }, + "title": "タㇰ", "name": "レ", "postCount": "ミンタㇻ アオ ペ チピㇱキㇷ゚", "firstRecordedAt": "ホㇱキ アヌイェ ト ヘㇺ トキ", diff --git a/frontend/src/locales/ja/common.json b/frontend/src/locales/ja/common.json index c8669f9..5497045 100644 --- a/frontend/src/locales/ja/common.json +++ b/frontend/src/locales/ja/common.json @@ -1,4 +1,5 @@ { + "loadingLabel": "Loading", "countLabel": "件数", "categoryLabel": "カテゴリ", "stateLabel": "状態", diff --git a/frontend/src/locales/ja/history.json b/frontend/src/locales/ja/history.json index 78bb061..9e1421d 100644 --- a/frontend/src/locales/ja/history.json +++ b/frontend/src/locales/ja/history.json @@ -1,4 +1,5 @@ { + "exportPath": "export_path", "postTitle": "耕作履歴", "tagTitle": "タグ定義変更履歴", "materialTitle": "素材履歴", diff --git a/frontend/src/locales/ja/imports.json b/frontend/src/locales/ja/imports.json index 6f741f3..eb4772d 100644 --- a/frontend/src/locales/ja/imports.json +++ b/frontend/src/locales/ja/imports.json @@ -1,4 +1,29 @@ { + "source": { + "title": "広場に投稿を追加", "urls": "URL リスト(1 行 1 URL)", + "restoreFailed": "保存済み入力を復元できませんでした", "saveFailed": "入力内容を保存できませんでした", + "urlRequired": "URL を入力してください.", "rowPrefix": "{{row}} 行目: ", + "lineCount": "入力行数 {{count}} / {{limit}}", "next": "次へ" + }, + "review": { + "title": "追加内容確認", "clearFailed": "入力内容を削除できませんでした", + "validateFailed": "行の再検証に失敗しました", "editPost": "投稿を編輯", + "incomplete": "登録結果が不完全でした", "retryFailed": "再試行に失敗しました", + "submitFailed": "登録に失敗しました", "existingSkipped": "既存投稿による自動スキップ {{count}}件", + "creatable": "作成対象 {{count}}件", "manualSkipped": "手動スキップ {{count}}件", + "pending": "未処理 {{count}}件", "back": "戻る", "addAll": "一括追加", + "reset": "変更をリセット", "save": "編輯内容を保存", "duration": "動画時間 {{duration}}" + }, + "validation": { + "duplicateUrl": "URL が重複しています.", "processingFailed": "登録中にエラーが発生しました.", + "tooMany": "取込件数は {{count}} 件までです.", "urlTooLong": "URL が長すぎます.", + "invalidUrl": "URL の形式が不正です.", "protocol": "HTTP または HTTPS の URL ではありません.", + "duplicateRow": "{{row}} 行目と同じ URL です." + }, + "storage": { + "readFailed": "保存済みデータを読み込めませんでした.", "writeFailed": "ブラウザへ保存できませんでした.", + "clearFailed": "保存済みデータを削除できませんでした." + }, "status": { "ready": "登録可能", "error": "登録不可", "warning": "警告", "skipped": "スキップ", "created": "登録済み", "failed": "登録失敗" diff --git a/frontend/src/locales/ja/navigation.json b/frontend/src/locales/ja/navigation.json index fd0eaf8..6f17274 100644 --- a/frontend/src/locales/ja/navigation.json +++ b/frontend/src/locales/ja/navigation.json @@ -1,4 +1,5 @@ -{ "posts": "広場", +{ "menuToggle": "Menu", + "posts": "広場", "postsWithCount": "広場 ({{count}})", "list": "一覧", "search": "検索", diff --git a/frontend/src/locales/ja/posts.json b/frontend/src/locales/ja/posts.json index d0c06aa..b6c1308 100644 --- a/frontend/src/locales/ja/posts.json +++ b/frontend/src/locales/ja/posts.json @@ -1,4 +1,26 @@ { + "list": { + "tagline": "{{brand}} 〜 ぼざろクリーチャーシリーズ綜合リンク集サイト", + "empty": "広場には何もありませんよ.", "viewWiki": "Wiki を見る", + "and": " and ", "or": " or " + }, + "search": { + "title": "広場検索", "matchType": "検索区分:", "all": "AND", "any": "OR", + "createdAt": "投稿日時" + }, + "detail": { + "networkFailed": "通信に失敗しました……", + "children": "この投稿には {{count}} 件の子投稿があります.", + "parent": "この投稿には 1 件の親投稿があります.", + "parentAndSiblings": "この投稿には 1 件の親投稿と {{count}} 件の姉妹投稿があります.", + "viewed": "閲覧済", "unviewed": "未閲覧", "emptyRelated": "まだないよ(笑)" + }, + "embed": { + "openExternal": "外部ページを開く", "load": "埋め込みを読込", + "confirmTitle": "未確認の外部ページを表示します。", + "risk": "悪意のあるスクリプトが実行される可能性があります。", + "question": "表示しますか?", "showExternal": "外部ページを表示" + }, "edit": { "validationFailed": "更新はできなかったよ……", "conflictTitle": "競合が発生しました.", diff --git a/frontend/src/locales/ja/shared.json b/frontend/src/locales/ja/shared.json index 5e755b0..38f9aae 100644 --- a/frontend/src/locales/ja/shared.json +++ b/frontend/src/locales/ja/shared.json @@ -1,4 +1,10 @@ { + "dialogue": { "confirm": "確定", "close": "閉ぢる" }, + "sidebarWidth": "サイドバー幅", + "url": "URL", + "idPrefix": "Id.: ", + "results": "検索結果", + "updateFailedMessage": "更新に失敗しました.", "labels": { "title": "タイトル", "content": "内容", "body": "本文", "lastUpdated": "最終更新", "thumbnail": "サムネール", diff --git a/frontend/src/locales/ja/tags.json b/frontend/src/locales/ja/tags.json index 593b79e..4eee9f8 100644 --- a/frontend/src/locales/ja/tags.json +++ b/frontend/src/locales/ja/tags.json @@ -1,4 +1,23 @@ -{ "title": "タグ", +{ "deprecated": "廃止済", + "nico": { + "discardConfirm": "編集中の内容を破棄しますか?", "discard": "破棄", + "updated": "連携を更新しました.", "updateFailed": "更新できませんでした.", + "loadFailed": "読込失敗", "loadFailedDescription": "ニコニコ連携を読み込めませんでした.", + "description": "ニコニコタグを広場のタグへ結び付けます.", + "linkedTags": "連携タグ", "linkStatus": "連携状態", + "all": "すべて", "linked": "連携済み", "unlinked": "未連携", + "firstRecorded": "最初に記載された日時", "recentRecorded": "最近記載された日時", + "edit": "編集", "tagsToLink": "連携する広場タグ", + "tagsPlaceholder": "タグ名を空白または改行で区切って入力" + }, + "relations": { + "added": "上位タグ対応追加", "removed": "上位タグ対応解除", "failed": "上位タグ対応失敗", + "addedDescription": "《{{child}}》を《{{parent}}》の子タグに設定しました.", + "removedDescription": "《{{child}}》を《{{parent}}》の子タグから外しました.", + "categoryChanged": "《{{name}}》のカテゴリを変更しました.", + "permissionRequired": "独裁者である必要があります." + }, + "title": "タグ", "name": "名前", "postCount": "広場の投稿数", "firstRecordedAt": "はじめて記載された日時", diff --git a/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx b/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx index a57c8d6..c176453 100644 --- a/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx +++ b/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx @@ -83,7 +83,7 @@ const DeerjikistDetailPage: FC = () => { return ( - {(loading || !(tag)) ? 'Loading...' : ( + {(loading || !(tag)) ? label ('common:loading') : (
diff --git a/frontend/src/pages/help/HelpDiffPage.tsx b/frontend/src/pages/help/HelpDiffPage.tsx index d41f2fe..ce29557 100644 --- a/frontend/src/pages/help/HelpDiffPage.tsx +++ b/frontend/src/pages/help/HelpDiffPage.tsx @@ -20,7 +20,7 @@ const markers = { added: '+ ', removed: '- ', unchanged: ' ' } const HelpDiffPage: FC = () => { - const { message } = useUITranslation ('help') + const { message, label } = useUITranslation ('help') const locale = useLocale () const { identifier = '' } = useParams () const [searchParams] = useSearchParams () @@ -85,7 +85,7 @@ const HelpDiffPage: FC = () => { ) - :
Loading...
} + :
{label ('common:loading')}
} ) } diff --git a/frontend/src/pages/materials/MaterialDetailPage.tsx b/frontend/src/pages/materials/MaterialDetailPage.tsx index 24b26da..d74f3bf 100644 --- a/frontend/src/pages/materials/MaterialDetailPage.tsx +++ b/frontend/src/pages/materials/MaterialDetailPage.tsx @@ -132,7 +132,7 @@ const MaterialDetailPage: FC = () => { {material.tag && ( - + diff --git a/frontend/src/pages/materials/MaterialHistoryPage.tsx b/frontend/src/pages/materials/MaterialHistoryPage.tsx index 6834128..3a29c84 100644 --- a/frontend/src/pages/materials/MaterialHistoryPage.tsx +++ b/frontend/src/pages/materials/MaterialHistoryPage.tsx @@ -160,8 +160,8 @@ const MaterialHistoryPage: FC = () => { {label ('version')} {label ('tagName')} {label ('filename')} - URL - export_path + {label ('shared:url')} + {label ('exportPath')} {label ('actor')} diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx index 9431c22..1991b28 100644 --- a/frontend/src/pages/posts/PostDetailPage.tsx +++ b/frontend/src/pages/posts/PostDetailPage.tsx @@ -19,6 +19,7 @@ import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { canEditContent } from '@/lib/users' import { cn } from '@/lib/utils' +import { useUITranslation } from '@/lib/useUITranslation' import NotFound from '@/pages/NotFound' import ServiceUnavailable from '@/pages/ServiceUnavailable' @@ -31,6 +32,7 @@ type Props = { user: User | null } const PostDetailPage: FC = ({ user }) => { const locale = useLocale () + const { label } = useUITranslation ('posts') const editable = canEditContent (user) const behaviourSettings = useClientBehaviourSettings () const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal' @@ -72,7 +74,8 @@ const PostDetailPage: FC = ({ user }) => { onError: (...[, , ctx]) => { if (ctx?.prev) qc.setQueryData (postKey, ctx.prev) - toast ({ title: '失敗……', description: '通信に失敗しました……' }) + toast ({ title: label ('shared:messages.failed'), + description: label ('detail.networkFailed') }) }, onSuccess: () => { qc.invalidateQueries ({ queryKey: postsKeys.root })} }) @@ -122,7 +125,7 @@ const PostDetailPage: FC = ({ user }) => { <> {(post.childPosts ?? []).length > 0 && (
-

この投稿には {post.childPosts!.length} 件の子投稿があります.

+

{label ('detail.children', { count: post.childPosts!.length })}

({ ...p, parentPosts: [{ } as Post] }))]}/> @@ -137,9 +140,9 @@ const PostDetailPage: FC = ({ user }) => { key={pp.id} className="mb-4 bg-yellow-200 dark:bg-yellow-800 text-sm p-2 rounded-md">

- この投稿には 1 件の親投稿{ - siblings.length > 1 - && `と ${ siblings.length - 1 } 件の姉妹投稿`}があります. + {siblings.length > 1 + ? label ('detail.parentAndSiblings', { count: siblings.length - 1 }) + : label ('detail.parent')}

({ @@ -170,16 +173,16 @@ const PostDetailPage: FC = ({ user }) => { - + {post.related.length > 0 ? - : 'まだないよ(笑)'} + : label ('detail.emptyRelated')} {editable && ( - + { @@ -191,7 +194,7 @@ const PostDetailPage: FC = ({ user }) => { )} ) - : 'Loading...'} + : label ('common:loading')} {post && } diff --git a/frontend/src/pages/posts/PostHistoryPage.tsx b/frontend/src/pages/posts/PostHistoryPage.tsx index 26b1688..db57494 100644 --- a/frontend/src/pages/posts/PostHistoryPage.tsx +++ b/frontend/src/pages/posts/PostHistoryPage.tsx @@ -195,7 +195,7 @@ const PostHistoryPage: FC = () => { {label ('post')} {label ('version')} {label ('shared:labels.title')} - URL + {label ('shared:url')} {label ('navigation:tags')} {/* TODO: 親投稿の履歴 */} {/* 親投稿 */} diff --git a/frontend/src/pages/posts/PostImportReviewPage.tsx b/frontend/src/pages/posts/PostImportReviewPage.tsx index 6c636bc..ba3ee4d 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.tsx @@ -11,10 +11,13 @@ import PrefetchLink from '@/components/PrefetchLink' import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview' import PostImportRowForm from '@/components/posts/import/PostImportRowForm' import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary' +import { renderPostImportMessage } from '@/components/posts/import/postImportRowStatus' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { apiGet, apiPost, isApiError } from '@/lib/api' +import { translateUIMessage } from '@/lib/i18n' +import { useUITranslation } from '@/lib/useUITranslation' import useDialogue from '@/lib/dialogues/useDialogue' import { parsePostNewReviewUrls } from '@/lib/postNewQueryState' import { validateImportSource } from '@/lib/postImportSourceValidation' @@ -100,8 +103,9 @@ type ExistingSkippedRowsProps = { id: string rows: PostImportRow[] } -const DUPLICATE_URL_MESSAGE = 'URL が重複しています.' -const BULK_PROCESSING_FAILED_MESSAGE = '登録中にエラーが発生しました.' +const DUPLICATE_URL_MESSAGE = translateUIMessage ('validation.duplicateUrl', 'imports', 'ja').text +const BULK_PROCESSING_FAILED_MESSAGE = + translateUIMessage ('validation.processingFailed', 'imports', 'ja').text const emptyAttributes = () => ({ title: '', @@ -597,6 +601,7 @@ const editableRowDirty = (row: PostImportRow): boolean => const PostImportReviewPage: FC = ({ user }) => { const locale = useLocale () + const { message, label } = useUITranslation ('imports') const editable = canEditContent (user) const dialogue = useDialogue () const location = useLocation () @@ -651,9 +656,10 @@ const PostImportReviewPage: FC = ({ user }) => { const finishImport = useCallback (() => { allowNextNavigation () clearPostImportSourceDraft (message => - toast ({ title: '入力内容を削除できませんでした', description: message })) + toast ({ title: label ('review.clearFailed'), + description: renderPostImportMessage (message, locale) })) navigate ('/posts') - }, [allowNextNavigation, navigate]) + }, [allowNextNavigation, navigate, label, locale]) useEffect (() => { const urls = parsePostNewReviewUrls (location.search) @@ -912,7 +918,7 @@ const PostImportReviewPage: FC = ({ user }) => { return { saved: false, row: applyThumbnailWarning (errorRow) } } - toast ({ title: '行の再検証に失敗しました' }) + toast ({ title: label ('review.validateFailed') }) return { saved: false, row: null } } } @@ -926,8 +932,8 @@ const PostImportReviewPage: FC = ({ user }) => { saveDraft (row, { draft, resetRequested }) await dialogue.form ({ - title: '投稿を編輯', - cancelText: '取消', + title: label ('review.editPost'), + cancelText: label ('shared:actions.cancel'), size: 'large', body: controls => ( = ({ user }) => { if (result.results.length !== 1) { updateRows (currentRows => replaceImportRow (currentRows, originalRow)) - toast ({ title: '登録結果が不完全でした' }) + toast ({ title: label ('review.incomplete') }) return } @@ -995,7 +1001,7 @@ const PostImportReviewPage: FC = ({ user }) => { catch { updateRows (currentRows => replaceImportRow (currentRows, originalRow)) - toast ({ title: '再試行に失敗しました' }) + toast ({ title: label ('review.retryFailed') }) } finally { @@ -1023,7 +1029,7 @@ const PostImportReviewPage: FC = ({ user }) => { buildBulkFormData (latestProcessingRows), { locale }) if (result.results.length !== latestProcessingRows.length) { - toast ({ title: '登録結果が不完全でした' }) + toast ({ title: label ('review.incomplete') }) return } @@ -1037,7 +1043,7 @@ const PostImportReviewPage: FC = ({ user }) => { } catch { - toast ({ title: '登録に失敗しました' }) + toast ({ title: label ('review.submitFailed') }) } finally { @@ -1054,12 +1060,12 @@ const PostImportReviewPage: FC = ({ user }) => { return ( <> - {`追加内容確認 | ${ SITE_TITLE }`} + {message ('review.title').text} | {SITE_TITLE}
- 追加内容確認 + {label ('review.title')} {existingRows.length > 0 && (
@@ -1078,7 +1084,7 @@ const PostImportReviewPage: FC = ({ user }) => { ? '' : 'transition-transform')}/> - 既存投稿による自動スキップ {counts.existingSkipped}件 + {label ('review.existingSkipped', { count: counts.existingSkipped })} {showExistingRows && ( @@ -1156,36 +1162,39 @@ const PostImportFooter = ( pendingOrErrorCount: number onBack: () => void onSubmit: () => void }, -) => ( -
-
-
- 作成対象 {creatableCount}件 - 手動スキップ {manualSkippedCount}件 - 既存投稿による自動スキップ {existingSkippedCount}件 - 未処理 {pendingOrErrorCount}件 +) => { + const { label } = useUITranslation ('imports') + return ( +
+
+
+ {label ('review.creatable', { count: creatableCount })} + {label ('review.manualSkipped', { count: manualSkippedCount })} + {label ('review.existingSkipped', { count: existingSkippedCount })} + {label ('review.pending', { count: pendingOrErrorCount })} +
+
+ + +
-
- - -
-
-
) +
) +} export default PostImportReviewPage diff --git a/frontend/src/pages/posts/PostImportSourcePage.tsx b/frontend/src/pages/posts/PostImportSourcePage.tsx index cc0783a..618cc4d 100644 --- a/frontend/src/pages/posts/PostImportSourcePage.tsx +++ b/frontend/src/pages/posts/PostImportSourcePage.tsx @@ -2,7 +2,9 @@ import { useEffect, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' -import { useLocaleNavigate } from '@/lib/useLocale' +import { useLocale, useLocaleNavigate } from '@/lib/useLocale' +import { useUITranslation } from '@/lib/useUITranslation' +import { renderPostImportMessage } from '@/components/posts/import/postImportRowStatus' import FieldError from '@/components/common/FieldError' import Form from '@/components/common/Form' import FormField from '@/components/common/FormField' @@ -40,6 +42,8 @@ const SOURCE_ISSUES_ID = 'post-import-source-issues' const PostImportSourcePage: FC = ({ user }) => { + const locale = useLocale () + const { message: uiMessage, label } = useUITranslation ('imports') const editable = canEditContent (user) const navigate = useLocaleNavigate () const [source, setSource] = useState ('') @@ -52,7 +56,7 @@ const PostImportSourcePage: FC = ({ user }) => { const lineCount = countImportSourceLines (source) const sourceUrls = extractImportSourceUrls (source) const withinPathLimit = isPostNewReviewPathWithinLimit (sourceUrls) - const messages = sourceError != null ? [sourceError] : [] + const messages = sourceError != null ? [label ('source.urlRequired')] : [] const sourceDescribedBy = [sourceError != null ? SOURCE_ERROR_ID : null, sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null] @@ -61,7 +65,8 @@ const PostImportSourcePage: FC = ({ user }) => { useEffect (() => { const draft = loadPostImportSourceDraft (message => - toast ({ title: '保存済み入力を復元できませんでした', description: message })) + toast ({ title: label ('source.restoreFailed'), + description: renderPostImportMessage (message, locale) })) if (!(editedRef.current)) { @@ -70,7 +75,7 @@ const PostImportSourcePage: FC = ({ user }) => { ? draft.source : current) } - }, []) + }, [label, locale]) useEffect (() => { if (saveTimer.current != null) @@ -78,20 +83,21 @@ const PostImportSourcePage: FC = ({ user }) => { saveTimer.current = window.setTimeout (() => { savePostImportSourceDraft (source, message => - toast ({ title: '入力内容を保存できませんでした', description: message })) + toast ({ title: label ('source.saveFailed'), + description: renderPostImportMessage (message, locale) })) }, 300) return () => { if (saveTimer.current != null) window.clearTimeout (saveTimer.current) } - }, [source]) + }, [source, label, locale]) const preview = async () => { if (lineCount === 0) { setSourceIssues ([]) - setSourceError ('URL を入力してください.') + setSourceError (uiMessage ('source.urlRequired').text) return } @@ -124,12 +130,12 @@ const PostImportSourcePage: FC = ({ user }) => { return ( - {`広場に投稿を追加 | ${ SITE_TITLE }`} + {uiMessage ('source.title').text} | {SITE_TITLE}
- 広場に投稿を追加 - + {label ('source.title')} + {() => (