このコミットが含まれているのは:
@@ -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>)}
|
||||
</>)
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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> = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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 ?? []))]
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
|
||||
@@ -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> = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする