このコミットが含まれているのは:
@@ -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,11 +42,12 @@ 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>)
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -26,7 +26,7 @@ type ChoiceOptions<T extends string> = { 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<boolean> }
|
||||
|
||||
type DialogueFormOptions = { title: string
|
||||
type DialogueFormOptions = { title: ReactNode
|
||||
description?: ReactNode
|
||||
body: (controls: DialogueFormControls) => ReactNode
|
||||
cancelText?: string
|
||||
cancelText?: ReactNode
|
||||
size?: 'default' | 'large' }
|
||||
|
||||
type DialogueAPI =
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string | number>) => {
|
||||
const message = useCallback ((key: string, values?: Record<string, string | number>) => {
|
||||
return translateUIMessage (key, namespace, locale, values)
|
||||
}
|
||||
}, [namespace, locale])
|
||||
|
||||
const label = (key: string, values?: Record<string, string | number>) => {
|
||||
const label = useCallback ((key: string, values?: Record<string, string | number>) => {
|
||||
const result = message (key, values)
|
||||
return createElement (LocalisedText, {
|
||||
language: result.language, children: result.text })
|
||||
}
|
||||
}, [message])
|
||||
|
||||
return { message, label }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{ "countLabel": "チピㇱキㇷ゚",
|
||||
{ "loadingLabel": "$t(common:loading)",
|
||||
"countLabel": "チピㇱキㇷ゚",
|
||||
"categoryLabel": "イソネカ",
|
||||
"stateLabel": "シㇼキ",
|
||||
"search": "イフナラ",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
{
|
||||
"unavailable": {
|
||||
"title": "Gekanator タネ シサㇺ イタㇰ アニ パテㇰ エシノッ エアㇱカイ!",
|
||||
"back": "ホㇱキ ソㇱ ウン ホㇱピ",
|
||||
"switchToJapanese": "シサㇺ イタㇰ ウン シンナ",
|
||||
"imageAlt": "ポロンノ チㇱ コㇿ アン 洗澡鹿"
|
||||
}
|
||||
}
|
||||
{ "unavailable": {
|
||||
"title": "Gekanator タネ シサㇺ イタㇰ アニ パテㇰ エシノッ エアㇱカイ!",
|
||||
"back": "ホㇱキ ソㇱ ウン ホㇱピ",
|
||||
"switchToJapanese": "シサㇺ イタㇰ ウン シンナ",
|
||||
"imageAlt": "ポロンノ チㇱ コㇿ アン シチャウカ ユㇰ ニチカ" } }
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{ "postTitle": "$t(tags:history)",
|
||||
{ "_untranslated": ["exportPath"],
|
||||
"exportPath": "export_path",
|
||||
"postTitle": "$t(tags:history)",
|
||||
"tagTitle": "タㇰ イヌイェㇷ゚ フㇱコアンペ",
|
||||
"materialTitle": "チカㇻペ フㇱコアンペ",
|
||||
"postPrefix": ":ミンタㇻ ",
|
||||
|
||||
@@ -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": "イパㇰカㇱヌ",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{ "posts": "ミンタㇻ",
|
||||
{ "menuToggle": "$t(shared:labels.menu)",
|
||||
"posts": "ミンタㇻ",
|
||||
"postsWithCount": "ミンタㇻ({{count}})",
|
||||
"list": "オピッタ",
|
||||
"search": "イフナラ",
|
||||
|
||||
@@ -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": "タネ エカㇻ シンナ ペ ネコン エキ クス ネ ヤ?",
|
||||
|
||||
@@ -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": "ヌイェㇷ゚",
|
||||
|
||||
@@ -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": "ホㇱキ アヌイェ ト ヘㇺ トキ",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"loadingLabel": "Loading",
|
||||
"countLabel": "件数",
|
||||
"categoryLabel": "カテゴリ",
|
||||
"stateLabel": "状態",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"exportPath": "export_path",
|
||||
"postTitle": "耕作履歴",
|
||||
"tagTitle": "タグ定義変更履歴",
|
||||
"materialTitle": "素材履歴",
|
||||
|
||||
@@ -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": "登録失敗"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{ "posts": "広場",
|
||||
{ "menuToggle": "Menu",
|
||||
"posts": "広場",
|
||||
"postsWithCount": "広場 ({{count}})",
|
||||
"list": "一覧",
|
||||
"search": "検索",
|
||||
|
||||
@@ -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": "競合が発生しました.",
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"dialogue": { "confirm": "確定", "close": "閉ぢる" },
|
||||
"sidebarWidth": "サイドバー幅",
|
||||
"url": "URL",
|
||||
"idPrefix": "Id.: ",
|
||||
"results": "検索結果",
|
||||
"updateFailedMessage": "更新に失敗しました.",
|
||||
"labels": {
|
||||
"title": "タイトル", "content": "内容", "body": "本文",
|
||||
"lastUpdated": "最終更新", "thumbnail": "サムネール",
|
||||
|
||||
@@ -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": "はじめて記載された日時",
|
||||
|
||||
@@ -83,7 +83,7 @@ const DeerjikistDetailPage: FC = () => {
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
{(loading || !(tag)) ? 'Loading...' : (
|
||||
{(loading || !(tag)) ? label ('common:loading') : (
|
||||
<div className="max-w-xl">
|
||||
<PageTitle>
|
||||
<TagLink tag={tag} withWiki={false} withCount={false}/>
|
||||
|
||||
@@ -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 = () => {
|
||||
</code>
|
||||
</pre>
|
||||
</>)
|
||||
: <div>Loading...</div>}
|
||||
: <div>{label ('common:loading')}</div>}
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ const MaterialDetailPage: FC = () => {
|
||||
|
||||
<TabGroup>
|
||||
{material.tag && (
|
||||
<Tab name="Wiki">
|
||||
<Tab name={label ('navigation:wiki')}>
|
||||
<WikiBody
|
||||
title={material.tag.name}
|
||||
body={material.wikiPageBody ?? undefined}/>
|
||||
|
||||
@@ -160,8 +160,8 @@ const MaterialHistoryPage: FC = () => {
|
||||
<th className="p-2 text-left">{label ('version')}</th>
|
||||
<th className="p-2 text-left">{label ('tagName')}</th>
|
||||
<th className="p-2 text-left">{label ('filename')}</th>
|
||||
<th className="p-2 text-left">URL</th>
|
||||
<th className="p-2 text-left">export_path</th>
|
||||
<th className="p-2 text-left">{label ('shared:url')}</th>
|
||||
<th className="p-2 text-left">{label ('exportPath')}</th>
|
||||
<th className="p-2 text-left">{label ('actor')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ user }) => {
|
||||
<>
|
||||
{(post.childPosts ?? []).length > 0 && (
|
||||
<div className="mb-4 bg-green-200 dark:bg-green-800 text-sm p-2 rounded-md">
|
||||
<p>この投稿には {post.childPosts!.length} 件の子投稿があります.</p>
|
||||
<p>{label ('detail.children', { count: post.childPosts!.length })}</p>
|
||||
<PostList posts={[{ ...post, childPosts: [{ } as Post] },
|
||||
...post.childPosts!.map (p => ({
|
||||
...p, parentPosts: [{ } as Post] }))]}/>
|
||||
@@ -137,9 +140,9 @@ const PostDetailPage: FC<Props> = ({ user }) => {
|
||||
key={pp.id}
|
||||
className="mb-4 bg-yellow-200 dark:bg-yellow-800 text-sm p-2 rounded-md">
|
||||
<p>
|
||||
この投稿には 1 件の親投稿{
|
||||
siblings.length > 1
|
||||
&& `と ${ siblings.length - 1 } 件の姉妹投稿`}があります.
|
||||
{siblings.length > 1
|
||||
? label ('detail.parentAndSiblings', { count: siblings.length - 1 })
|
||||
: label ('detail.parent')}
|
||||
</p>
|
||||
<PostList posts={[{ ...pp, childPosts: [{ } as Post] },
|
||||
...siblings.map (p => ({
|
||||
@@ -170,16 +173,16 @@ const PostDetailPage: FC<Props> = ({ user }) => {
|
||||
<Button onClick={() => changeViewedFlg.mutate (!(post.viewed))}
|
||||
disabled={changeViewedFlg.isPending}
|
||||
className={cn ('text-white', viewedClass)}>
|
||||
{post.viewed ? '閲覧済' : '未閲覧'}
|
||||
{label (post.viewed ? 'detail.viewed' : 'detail.unviewed')}
|
||||
</Button>
|
||||
<TabGroup>
|
||||
<Tab name="関聯">
|
||||
<Tab name={label ('shared:labels.relations')}>
|
||||
{post.related.length > 0
|
||||
? <PostList posts={post.related}/>
|
||||
: 'まだないよ(笑)'}
|
||||
: label ('detail.emptyRelated')}
|
||||
</Tab>
|
||||
{editable && (
|
||||
<Tab name="編輯">
|
||||
<Tab name={label ('shared:actions.edit')}>
|
||||
<PostEditForm
|
||||
post={post}
|
||||
onSave={newPost => {
|
||||
@@ -191,7 +194,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
|
||||
</Tab>)}
|
||||
</TabGroup>
|
||||
</>)
|
||||
: 'Loading...'}
|
||||
: label ('common:loading')}
|
||||
</MainArea>
|
||||
|
||||
{post && <TagDetailSidebar post={post} sp className="md:hidden"/>}
|
||||
|
||||
@@ -195,7 +195,7 @@ const PostHistoryPage: FC = () => {
|
||||
<th className="p-2 text-left">{label ('post')}</th>
|
||||
<th className="p-2 text-left">{label ('version')}</th>
|
||||
<th className="p-2 text-left">{label ('shared:labels.title')}</th>
|
||||
<th className="p-2 text-left">URL</th>
|
||||
<th className="p-2 text-left">{label ('shared:url')}</th>
|
||||
<th className="p-2 text-left">{label ('navigation:tags')}</th>
|
||||
{/* TODO: 親投稿の履歴 */}
|
||||
{/* <th className="p-2 text-left">親投稿</th> */}
|
||||
|
||||
@@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ user }) => {
|
||||
saveDraft (row, { draft, resetRequested })
|
||||
|
||||
await dialogue.form ({
|
||||
title: '投稿を編輯',
|
||||
cancelText: '取消',
|
||||
title: label ('review.editPost'),
|
||||
cancelText: label ('shared:actions.cancel'),
|
||||
size: 'large',
|
||||
body: controls => (
|
||||
<PostImportRowForm
|
||||
@@ -980,7 +986,7 @@ const PostImportReviewPage: FC<Props> = ({ 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<Props> = ({ user }) => {
|
||||
catch
|
||||
{
|
||||
updateRows (currentRows => replaceImportRow (currentRows, originalRow))
|
||||
toast ({ title: '再試行に失敗しました' })
|
||||
toast ({ title: label ('review.retryFailed') })
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1023,7 +1029,7 @@ const PostImportReviewPage: FC<Props> = ({ 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<Props> = ({ user }) => {
|
||||
}
|
||||
catch
|
||||
{
|
||||
toast ({ title: '登録に失敗しました' })
|
||||
toast ({ title: label ('review.submitFailed') })
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1054,12 +1060,12 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{`追加内容確認 | ${ SITE_TITLE }`}</title>
|
||||
<title>{message ('review.title').text} | {SITE_TITLE}</title>
|
||||
</Helmet>
|
||||
|
||||
<MainArea className="pb-40">
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<PageTitle>追加内容確認</PageTitle>
|
||||
<PageTitle>{label ('review.title')}</PageTitle>
|
||||
|
||||
{existingRows.length > 0 && (
|
||||
<div className="rounded-lg border p-4">
|
||||
@@ -1078,7 +1084,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
? ''
|
||||
: 'transition-transform')}/>
|
||||
</span>
|
||||
既存投稿による自動スキップ {counts.existingSkipped}件
|
||||
{label ('review.existingSkipped', { count: counts.existingSkipped })}
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{showExistingRows && (
|
||||
@@ -1156,36 +1162,39 @@ const PostImportFooter = (
|
||||
pendingOrErrorCount: number
|
||||
onBack: () => void
|
||||
onSubmit: () => void },
|
||||
) => (
|
||||
<div
|
||||
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
|
||||
dark:border-neutral-700 dark:bg-neutral-950/95">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
|
||||
md:items-center md:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>作成対象 {creatableCount}件</span>
|
||||
<span>手動スキップ {manualSkippedCount}件</span>
|
||||
<span>既存投稿による自動スキップ {existingSkippedCount}件</span>
|
||||
<span>未処理 {pendingOrErrorCount}件</span>
|
||||
) => {
|
||||
const { label } = useUITranslation ('imports')
|
||||
return (
|
||||
<div
|
||||
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
|
||||
dark:border-neutral-700 dark:bg-neutral-950/95">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
|
||||
md:items-center md:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>{label ('review.creatable', { count: creatableCount })}</span>
|
||||
<span>{label ('review.manualSkipped', { count: manualSkippedCount })}</span>
|
||||
<span>{label ('review.existingSkipped', { count: existingSkippedCount })}</span>
|
||||
<span>{label ('review.pending', { count: pendingOrErrorCount })}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full md:w-auto"
|
||||
onClick={onBack}
|
||||
disabled={submitting}>
|
||||
{label ('review.back')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full md:w-auto"
|
||||
onClick={onSubmit}
|
||||
disabled={metadataLoading || submitting || busyRowsPresent || !(canSubmit)}>
|
||||
{creatableCount > 1 ? label ('review.addAll') : label ('shared:actions.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full md:w-auto"
|
||||
onClick={onBack}
|
||||
disabled={submitting}>
|
||||
戻る
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full md:w-auto"
|
||||
onClick={onSubmit}
|
||||
disabled={metadataLoading || submitting || busyRowsPresent || !(canSubmit)}>
|
||||
{creatableCount > 1 ? '一括追加' : '追加'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>)
|
||||
</div>)
|
||||
}
|
||||
|
||||
export default PostImportReviewPage
|
||||
|
||||
@@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ user }) => {
|
||||
? draft.source
|
||||
: current)
|
||||
}
|
||||
}, [])
|
||||
}, [label, locale])
|
||||
|
||||
useEffect (() => {
|
||||
if (saveTimer.current != null)
|
||||
@@ -78,20 +83,21 @@ const PostImportSourcePage: FC<Props> = ({ 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<Props> = ({ user }) => {
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`広場に投稿を追加 | ${ SITE_TITLE }`}</title>
|
||||
<title>{uiMessage ('source.title').text} | {SITE_TITLE}</title>
|
||||
</Helmet>
|
||||
|
||||
<Form className="max-w-4xl">
|
||||
<PageTitle>広場に投稿を追加</PageTitle>
|
||||
<FormField label="URL リスト(1 行 1 URL)">
|
||||
<PageTitle>{label ('source.title')}</PageTitle>
|
||||
<FormField label={label ('source.urls')}>
|
||||
{() => (
|
||||
<TextArea
|
||||
value={source}
|
||||
@@ -140,8 +146,8 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
onBlur={() => {
|
||||
savePostImportSourceDraft (source, message =>
|
||||
toast ({
|
||||
title: '入力内容を保存できませんでした',
|
||||
description: message }))
|
||||
title: label ('source.saveFailed'),
|
||||
description: renderPostImportMessage (message, locale) }))
|
||||
}}
|
||||
onChange={ev => {
|
||||
editedRef.current = true
|
||||
@@ -158,21 +164,24 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
className="space-y-2 text-sm text-red-700 dark:text-red-300">
|
||||
{sourceIssues.map (issue => (
|
||||
<li key={`${ issue.sourceRow }-${ issue.message }-${ issue.url }`}>
|
||||
<div>{issue.sourceRow} 行目: {issue.message}</div>
|
||||
<div>
|
||||
{label ('source.rowPrefix', { row: issue.sourceRow })}
|
||||
{renderPostImportMessage (issue.message, locale)}
|
||||
</div>
|
||||
<div className="break-all font-mono text-xs">{issue.url}</div>
|
||||
</li>))}
|
||||
</ul>
|
||||
|
||||
<div className="relative z-10 flex items-center justify-between gap-3">
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
入力行数 {lineCount} / {MAX_ROWS}
|
||||
{label ('source.lineCount', { count: lineCount, limit: MAX_ROWS })}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="pointer-events-auto shrink-0"
|
||||
onClick={preview}
|
||||
disabled={loading || !(withinPathLimit)}>
|
||||
次へ
|
||||
{label ('source.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '@/lib/settings'
|
||||
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
||||
import { fetchWikiPageByTitle } from '@/lib/wiki'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
@@ -35,6 +36,7 @@ const parseLimit = (value: string | null): 20 | 50 | 100 | null => {
|
||||
|
||||
const PostListPage: FC = () => {
|
||||
const locale = useLocale ()
|
||||
const { message, label } = useUITranslation ('posts')
|
||||
const containerRef = useRef<HTMLDivElement | null> (null)
|
||||
|
||||
const [wikiPage, setWikiPage] = useState<WikiPage | null> (null)
|
||||
@@ -130,8 +132,8 @@ const PostListPage: FC = () => {
|
||||
<Helmet>
|
||||
<title>
|
||||
{tags.length
|
||||
? `${ tags.join (anyFlg ? ' or ' : ' and ') } | ${ SITE_TITLE }`
|
||||
: `${ SITE_TITLE }\u3000〜 ぼざろクリーチャーシリーズ綜合リンク集サイト`}
|
||||
? `${ tags.join (message (anyFlg ? 'list.or' : 'list.and').text) } | ${ SITE_TITLE }`
|
||||
: message ('list.tagline', { brand: SITE_TITLE }).text}
|
||||
</title>
|
||||
</Helmet>
|
||||
|
||||
@@ -147,7 +149,7 @@ const PostListPage: FC = () => {
|
||||
<TabGroup
|
||||
rightAddon={
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>件数</span>
|
||||
<span>{label ('common:countLabel')}</span>
|
||||
<select
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm
|
||||
text-foreground"
|
||||
@@ -162,22 +164,22 @@ const PostListPage: FC = () => {
|
||||
</select>
|
||||
</label>
|
||||
}>
|
||||
<Tab name="広場">
|
||||
<Tab name={label ('navigation:posts')}>
|
||||
{posts.length > 0
|
||||
? (
|
||||
<>
|
||||
<PostList posts={posts}/>
|
||||
<Pagination page={page} totalPages={totalPages}/>
|
||||
</>)
|
||||
: !(loading) && '広場には何もありませんよ.'}
|
||||
{loading && 'Loading...'}
|
||||
: !(loading) && label ('list.empty')}
|
||||
{loading && label ('common:loading')}
|
||||
</Tab>
|
||||
{tags.length === 1 && (
|
||||
<Tab name="Wiki">
|
||||
<Tab name={label ('navigation:wiki')}>
|
||||
<WikiBody title={tags[0]} body={wikiPage?.body}/>
|
||||
<div className="my-2">
|
||||
<PrefetchLink to={`/wiki/${ encodeURIComponent (tags[0]) }`}>
|
||||
Wiki を見る
|
||||
{label ('list.viewWiki')}
|
||||
</PrefetchLink>
|
||||
</div>
|
||||
</Tab>)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import SortHeader from '@/components/SortHeader'
|
||||
import TagLink from '@/components/TagLink'
|
||||
import DateTimeField from '@/components/common/DateTimeField'
|
||||
@@ -29,7 +30,8 @@ import {
|
||||
} from '@/lib/settings'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
||||
import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils'
|
||||
import { localisedDateString, inputClass, originalCreatedAtString } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { FC, FormEvent } from 'react'
|
||||
|
||||
@@ -72,6 +74,7 @@ const parseOrder = (value: string | null): FetchPostsOrder | null => {
|
||||
|
||||
const PostSearchPage: FC = () => {
|
||||
const locale = useLocale ()
|
||||
const { message, label } = useUITranslation ('posts')
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||
const thumbnailTransition = clientAnimationTransition (
|
||||
@@ -223,14 +226,14 @@ const PostSearchPage: FC = () => {
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>広場検索 | {SITE_TITLE}</title>
|
||||
<title>{message ('search.title').text} | {SITE_TITLE}</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="max-w-xl">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<PageTitle>広場検索</PageTitle>
|
||||
<PageTitle>{label ('search.title')}</PageTitle>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>件数</span>
|
||||
<span>{label ('common:countLabel')}</span>
|
||||
<select
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm
|
||||
text-foreground"
|
||||
@@ -248,7 +251,7 @@ const PostSearchPage: FC = () => {
|
||||
|
||||
<form onSubmit={handleSearch} className="space-y-2">
|
||||
{/* タイトル */}
|
||||
<FormField label="タイトル">
|
||||
<FormField label={label ('shared:labels.title')}>
|
||||
{({ invalid }) => (
|
||||
<input
|
||||
type="text"
|
||||
@@ -259,7 +262,7 @@ const PostSearchPage: FC = () => {
|
||||
</FormField>
|
||||
|
||||
{/* URL */}
|
||||
<FormField label="URL">
|
||||
<FormField label={label ('shared:url')}>
|
||||
{({ invalid }) => (
|
||||
<input
|
||||
type="text"
|
||||
@@ -269,7 +272,7 @@ const PostSearchPage: FC = () => {
|
||||
</FormField>
|
||||
|
||||
{/* タグ */}
|
||||
<FormField label="タグ">
|
||||
<FormField label={label ('navigation:tags')}>
|
||||
{() => (
|
||||
<TagInput
|
||||
includeNico={true}
|
||||
@@ -278,14 +281,14 @@ const PostSearchPage: FC = () => {
|
||||
</FormField>
|
||||
<div>
|
||||
<fieldset className="w-full my-2">
|
||||
<label>検索区分:</label>
|
||||
<label>{label ('search.matchType')}</label>
|
||||
<label className="mx-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="match-type"
|
||||
checked={matchType === 'all'}
|
||||
onChange={() => setMatchType ('all')}/>
|
||||
AND
|
||||
{label ('search.all')}
|
||||
</label>
|
||||
<label className="mx-2">
|
||||
<input
|
||||
@@ -293,13 +296,13 @@ const PostSearchPage: FC = () => {
|
||||
name="match-type"
|
||||
checked={matchType === 'any'}
|
||||
onChange={() => setMatchType ('any')}/>
|
||||
OR
|
||||
{label ('search.any')}
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{/* オリジナルの投稿日時 */}
|
||||
<FormField label="オリジナルの投稿日時">
|
||||
<FormField label={label ('history:originalPostedAt')}>
|
||||
{() => (
|
||||
<>
|
||||
<DateTimeField
|
||||
@@ -313,7 +316,7 @@ const PostSearchPage: FC = () => {
|
||||
</FormField>
|
||||
|
||||
{/* 投稿日時 */}
|
||||
<FormField label="投稿日時">
|
||||
<FormField label={label ('search.createdAt')}>
|
||||
{() => (
|
||||
<>
|
||||
<DateTimeField
|
||||
@@ -327,7 +330,7 @@ const PostSearchPage: FC = () => {
|
||||
</FormField>
|
||||
|
||||
{/* 更新日時 */}
|
||||
<FormField label="更新日時">
|
||||
<FormField label={label ('tags:updatedAt')}>
|
||||
{() => (
|
||||
<>
|
||||
<DateTimeField
|
||||
@@ -345,13 +348,13 @@ const PostSearchPage: FC = () => {
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded">
|
||||
検索
|
||||
{label ('common:search')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{loading ? 'Loading...' : (results.length > 0 ? (
|
||||
{loading ? label ('common:loading') : (results.length > 0 ? (
|
||||
<div className="mt-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1200px] table-fixed border-collapse">
|
||||
@@ -367,40 +370,40 @@ const PostSearchPage: FC = () => {
|
||||
|
||||
<thead className="border-b-2 border-black dark:border-white">
|
||||
<tr>
|
||||
<th className="p-2 text-left whitespace-nowrap">投稿</th>
|
||||
<th className="p-2 text-left whitespace-nowrap">{label ('history:post')}</th>
|
||||
<th className="p-2 text-left whitespace-nowrap">
|
||||
<SortHeader<FetchPostsOrderField>
|
||||
by="title"
|
||||
label="タイトル"
|
||||
label={label ('shared:labels.title')}
|
||||
currentOrder={order}
|
||||
defaultDirection={defaultDirection}/>
|
||||
</th>
|
||||
<th className="p-2 text-left whitespace-nowrap">
|
||||
<SortHeader<FetchPostsOrderField>
|
||||
by="url"
|
||||
label="URL"
|
||||
label={label ('shared:url')}
|
||||
currentOrder={order}
|
||||
defaultDirection={defaultDirection}/>
|
||||
</th>
|
||||
<th className="p-2 text-left whitespace-nowrap">タグ</th>
|
||||
<th className="p-2 text-left whitespace-nowrap">{label ('navigation:tags')}</th>
|
||||
<th className="p-2 text-left whitespace-nowrap">
|
||||
<SortHeader<FetchPostsOrderField>
|
||||
by="original_created_at"
|
||||
label="オリジナルの投稿日時"
|
||||
label={label ('history:originalPostedAt')}
|
||||
currentOrder={order}
|
||||
defaultDirection={defaultDirection}/>
|
||||
</th>
|
||||
<th className="p-2 text-left whitespace-nowrap">
|
||||
<SortHeader<FetchPostsOrderField>
|
||||
by="created_at"
|
||||
label="投稿日時"
|
||||
label={label ('search.createdAt')}
|
||||
currentOrder={order}
|
||||
defaultDirection={defaultDirection}/>
|
||||
</th>
|
||||
<th className="p-2 text-left whitespace-nowrap">
|
||||
<SortHeader<FetchPostsOrderField>
|
||||
by="updated_at"
|
||||
label="更新日時"
|
||||
label={label ('tags:updatedAt')}
|
||||
currentOrder={order}
|
||||
defaultDirection={defaultDirection}/>
|
||||
</th>
|
||||
@@ -445,18 +448,28 @@ const PostSearchPage: FC = () => {
|
||||
</span>))}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{originalCreatedAtString (row.originalCreatedFrom,
|
||||
row.originalCreatedBefore)}
|
||||
<LocalisedText language={locale}>
|
||||
{originalCreatedAtString (row.originalCreatedFrom,
|
||||
row.originalCreatedBefore, locale)}
|
||||
</LocalisedText>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<LocalisedText language={locale}>
|
||||
{localisedDateString (row.createdAt, locale)}
|
||||
</LocalisedText>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<LocalisedText language={locale}>
|
||||
{localisedDateString (row.updatedAt, locale)}
|
||||
</LocalisedText>
|
||||
</td>
|
||||
<td className="p-2">{dateString (row.createdAt)}</td>
|
||||
<td className="p-2">{dateString (row.updatedAt)}</td>
|
||||
</tr>))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination page={page} totalPages={totalPages}/>
|
||||
</div>) : '結果ないよ(笑)')}
|
||||
</div>) : label ('tags:empty'))}
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import FormField from '@/components/common/FormField'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import Pagination from '@/components/common/Pagination'
|
||||
import TextArea from '@/components/common/TextArea'
|
||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
@@ -22,7 +22,10 @@ import { clientScrollBehaviour } from '@/lib/clientAnimation'
|
||||
import { tagsKeys } from '@/lib/queryKeys'
|
||||
import { fetchNicoTags } from '@/lib/tags'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { cn, dateString, inputClass } from '@/lib/utils'
|
||||
import { cn, localisedDateString, inputClass } from '@/lib/utils'
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import { translateUIMessage } from '@/lib/i18n'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
|
||||
import type { FC, FormEvent } from 'react'
|
||||
@@ -42,6 +45,8 @@ const setIf = (qs: URLSearchParams, key: string, value: string) => {
|
||||
|
||||
const NicoTagListPage: FC<Props> = ({ user }) => {
|
||||
const locale = useLocale ()
|
||||
const { message, label } = useUITranslation ('tags')
|
||||
const fallbackUpdateError = translateUIMessage ('nico.updateFailed', 'tags', 'ja').text
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||
const dialogue = useDialogue ()
|
||||
@@ -102,8 +107,8 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
|
||||
|
||||
if (editingId != null && editingId !== tag.id && editingChanged
|
||||
&& !(await dialogue.confirm ({
|
||||
title: '編集中の内容を破棄しますか?',
|
||||
confirmText: '破棄',
|
||||
title: label ('nico.discardConfirm'),
|
||||
confirmText: label ('nico.discard'),
|
||||
variant: 'danger'})))
|
||||
return
|
||||
|
||||
@@ -134,7 +139,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
|
||||
setErrorsByTagId (errors => ({ ...errors, [id]: [] }))
|
||||
setEditingId (null)
|
||||
await queryClient.invalidateQueries ({ queryKey: tagsKeys.nicoRoot })
|
||||
toast ({ description: '連携を更新しました.' })
|
||||
toast ({ description: label ('nico.updated') })
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
@@ -143,8 +148,9 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
|
||||
...errors,
|
||||
[id]: validationError?.fieldErrors.tags
|
||||
?? validationError?.baseErrors
|
||||
?? ['更新できませんでした.']}))
|
||||
toast ({ title: '更新失敗', description: '入力内容を確認してください.' })
|
||||
?? [fallbackUpdateError]}))
|
||||
toast ({ title: label ('shared:messages.updateFailed'),
|
||||
description: label ('shared:messages.checkInputContent') })
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -173,52 +179,60 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
|
||||
|
||||
useEffect (() => {
|
||||
if (isError)
|
||||
toast ({ title: '読込失敗', description: 'ニコニコ連携を読み込めませんでした.' })
|
||||
}, [isError])
|
||||
{
|
||||
toast ({ title: label ('nico.loadFailed'),
|
||||
description: label ('nico.loadFailedDescription') })
|
||||
}
|
||||
}, [isError, label])
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>ニコニコ連携 | {SITE_TITLE}</title>
|
||||
<title>{message ('navigation:nicoLinks').text} | {SITE_TITLE}</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="max-w-xl">
|
||||
<PageTitle>ニコニコ連携</PageTitle>
|
||||
<PageTitle>{label ('navigation:nicoLinks')}</PageTitle>
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
ニコニコタグを広場のタグへ結び付けます.
|
||||
{label ('nico.description')}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSearch} className="space-y-2">
|
||||
<FormField label="ニコニコタグ">
|
||||
<FormField label={label ('common:categories.nico')}>
|
||||
{({ invalid }) => (
|
||||
<input
|
||||
type="text"
|
||||
aria-label="ニコニコタグ"
|
||||
aria-label={message ('common:categories.nico').text}
|
||||
lang={message ('common:categories.nico').language}
|
||||
value={name}
|
||||
onChange={e => setName (e.target.value)}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>
|
||||
|
||||
<FormField label="連携タグ">
|
||||
<FormField label={label ('nico.linkedTags')}>
|
||||
{({ invalid }) => (
|
||||
<input
|
||||
type="text"
|
||||
aria-label="連携タグ"
|
||||
aria-label={message ('nico.linkedTags').text}
|
||||
lang={message ('nico.linkedTags').language}
|
||||
value={linkedTag}
|
||||
onChange={e => setLinkedTag (e.target.value)}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>
|
||||
|
||||
<FormField label="連携状態">
|
||||
<FormField label={label ('nico.linkStatus')}>
|
||||
{({ invalid }) => (
|
||||
<select
|
||||
aria-label="連携状態"
|
||||
aria-label={message ('nico.linkStatus').text}
|
||||
lang={message ('nico.linkStatus').language}
|
||||
value={linkStatus}
|
||||
onChange={e => setLinkStatus (e.target.value as LinkStatus)}
|
||||
className={inputClass (invalid)}>
|
||||
<option value="all">すべて</option>
|
||||
<option value="linked">連携済み</option>
|
||||
<option value="unlinked">未連携</option>
|
||||
{(['all', 'linked', 'unlinked'] as const).map (value => (
|
||||
<option key={value} value={value}
|
||||
lang={message (`nico.${value}`).language}>
|
||||
{message (`nico.${value}`).text}
|
||||
</option>))}
|
||||
</select>)}
|
||||
</FormField>
|
||||
|
||||
@@ -226,19 +240,21 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-blue-500 px-4 py-2 text-white">
|
||||
検索
|
||||
{label ('common:search')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{loading
|
||||
? 'Loading...'
|
||||
? label ('common:loading')
|
||||
: (
|
||||
<div className="mt-6">
|
||||
<div className="mb-3 flex items-baseline justify-between gap-4">
|
||||
<h2 className="text-lg font-bold">検索結果</h2>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{count} 件</span>
|
||||
<h2 className="text-lg font-bold">{label ('shared:results')}</h2>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{label ('shared:labels.resultsCount', { count })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{nicoTags.length > 0
|
||||
@@ -257,22 +273,22 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
|
||||
<th className="p-2 text-left whitespace-nowrap">
|
||||
<SortHeader<FetchNicoTagsOrderField>
|
||||
by="name"
|
||||
label="ニコニコタグ"
|
||||
label={label ('common:categories.nico')}
|
||||
currentOrder={order}
|
||||
defaultDirection={defaultDirection}/>
|
||||
</th>
|
||||
<th className="p-2 text-left">連携タグ</th>
|
||||
<th className="p-2 text-left">{label ('nico.linkedTags')}</th>
|
||||
<th className="p-2 text-left whitespace-nowrap">
|
||||
<SortHeader<FetchNicoTagsOrderField>
|
||||
by="created_at"
|
||||
label="最初に記載された日時"
|
||||
label={label ('nico.firstRecorded')}
|
||||
currentOrder={order}
|
||||
defaultDirection={defaultDirection}/>
|
||||
</th>
|
||||
<th className="p-2 text-left whitespace-nowrap">
|
||||
<SortHeader<FetchNicoTagsOrderField>
|
||||
by="updated_at"
|
||||
label="最近記載された日時"
|
||||
label={label ('nico.recentRecorded')}
|
||||
currentOrder={order}
|
||||
defaultDirection={defaultDirection}/>
|
||||
</th>
|
||||
@@ -282,6 +298,8 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
|
||||
<tbody>
|
||||
{nicoTags.map (tag => {
|
||||
const isEditing = editingId === tag.id
|
||||
const errors = errorsByTagId[tag.id]?.map (value =>
|
||||
value === fallbackUpdateError ? label ('nico.updateFailed') : value)
|
||||
|
||||
return (
|
||||
<Fragment key={tag.id}>
|
||||
@@ -306,10 +324,15 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
|
||||
</span>))}
|
||||
</td>
|
||||
<td className="p-2 align-top whitespace-nowrap">
|
||||
{dateString (tag.createdAt)}
|
||||
<LocalisedText language={locale}>
|
||||
{localisedDateString (tag.createdAt, locale)}
|
||||
</LocalisedText>
|
||||
</td>
|
||||
<td className="p-2 align-top whitespace-nowrap">
|
||||
{tag.recentPostTagCreatedAt && dateString (tag.recentPostTagCreatedAt)}
|
||||
{tag.recentPostTagCreatedAt && (
|
||||
<LocalisedText language={locale}>
|
||||
{localisedDateString (tag.recentPostTagCreatedAt, locale)}
|
||||
</LocalisedText>)}
|
||||
</td>
|
||||
{editable && (
|
||||
<td className="p-2 text-right align-top">
|
||||
@@ -320,7 +343,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
|
||||
className="inline-flex items-center gap-1 text-sm text-blue-700
|
||||
hover:underline dark:text-blue-300">
|
||||
<Pencil className="size-3.5"/>
|
||||
編集
|
||||
{label ('nico.edit')}
|
||||
</button>)}
|
||||
</td>)}
|
||||
</tr>
|
||||
@@ -332,18 +355,19 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={`nico-links-${ tag.id }`}
|
||||
className="block text-sm font-semibold">
|
||||
連携する広場タグ
|
||||
{label ('nico.tagsToLink')}
|
||||
</label>
|
||||
<TextArea
|
||||
id={`nico-links-${ tag.id }`}
|
||||
value={rawTags[tag.id] ?? ''}
|
||||
invalid={(errorsByTagId[tag.id] ?? []).length > 0}
|
||||
className="min-h-24 resize-y"
|
||||
placeholder="タグ名を空白または改行で区切って入力"
|
||||
placeholder={message ('nico.tagsPlaceholder').text}
|
||||
lang={message ('nico.tagsPlaceholder').language}
|
||||
onChange={e => setRawTags (rawTags => ({
|
||||
...rawTags,
|
||||
[tag.id]: e.target.value}))}/>
|
||||
<FieldError messages={errorsByTagId[tag.id]}/>
|
||||
<FieldError messages={errors}/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -353,7 +377,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
|
||||
border-gray-300 px-3 py-1.5 text-sm
|
||||
disabled:opacity-50 dark:border-gray-700">
|
||||
<X className="size-3.5"/>
|
||||
取消
|
||||
{label ('shared:actions.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -364,7 +388,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
|
||||
{savingId === tag.id
|
||||
? <LoaderCircle className="size-3.5 animate-spin"/>
|
||||
: <Check className="size-3.5"/>}
|
||||
保存
|
||||
{label ('settings:actions.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -375,7 +399,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>)
|
||||
: <p>結果ないよ(笑)</p>}
|
||||
: <p>{label ('empty')}</p>}
|
||||
|
||||
<Pagination page={page} totalPages={totalPages}/>
|
||||
</div>)}
|
||||
|
||||
@@ -9,12 +9,13 @@ import FormField from '@/components/common/FormField'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
|
||||
import { CATEGORIES } from '@/consts'
|
||||
import { apiPut } from '@/lib/api'
|
||||
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
|
||||
import { fetchTag } from '@/lib/tags'
|
||||
import { cn, inputClass } from '@/lib/utils'
|
||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { FC, FormEvent } from 'react'
|
||||
|
||||
@@ -30,6 +31,7 @@ type TagFormField =
|
||||
|
||||
const TagDetailPage: FC = () => {
|
||||
const locale = useLocale ()
|
||||
const { message, label } = useUITranslation ('tags')
|
||||
const { id } = useParams ()
|
||||
const tagId = String (id ?? '')
|
||||
const tagKey = tagsKeys.show (tagId, locale)
|
||||
@@ -72,12 +74,12 @@ const TagDetailPage: FC = () => {
|
||||
|
||||
qc.invalidateQueries ({ queryKey: postsKeys.root })
|
||||
qc.invalidateQueries ({ queryKey: tagsKeys.root })
|
||||
toast ({ description: '更新しました.' })
|
||||
toast ({ description: label ('shared:messages.updated') })
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
applyValidationError (e)
|
||||
toast ({ description: '更新に失敗しました.' })
|
||||
toast ({ description: label ('shared:updateFailedMessage') })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +100,7 @@ const TagDetailPage: FC = () => {
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
{(loading || !(tag)) ? 'Loading...' : (
|
||||
{(loading || !(tag)) ? label ('common:loading') : (
|
||||
<div className="max-w-xl">
|
||||
<PageTitle>
|
||||
<TagLink
|
||||
@@ -111,7 +113,7 @@ const TagDetailPage: FC = () => {
|
||||
<FieldError messages={baseErrors}/>
|
||||
|
||||
{/* 名称 */}
|
||||
<FormField label="名称" messages={fieldErrors.name}>
|
||||
<FormField label={label ('history:name')} messages={fieldErrors.name}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
{/* TODO: 補完に対応させる */}
|
||||
@@ -127,7 +129,7 @@ const TagDetailPage: FC = () => {
|
||||
</FormField>
|
||||
|
||||
{/* カテゴリ */}
|
||||
<FormField label="カテゴリ" messages={fieldErrors.category}>
|
||||
<FormField label={label ('common:categoryLabel')} messages={fieldErrors.category}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<select
|
||||
disabled={disabled}
|
||||
@@ -138,14 +140,15 @@ const TagDetailPage: FC = () => {
|
||||
className={inputClass (invalid)}>
|
||||
{CATEGORIES.filter (cat => tag.category === 'nico' || cat !== 'nico')
|
||||
.map (cat => (
|
||||
<option key={cat} value={cat}>
|
||||
{CATEGORY_NAMES[cat]}
|
||||
<option key={cat} value={cat}
|
||||
lang={message (`common:categories.${cat}`).language}>
|
||||
{message (`common:categories.${cat}`).text}
|
||||
</option>))}
|
||||
</select>)}
|
||||
</FormField>
|
||||
|
||||
{/* 別名 */}
|
||||
<FormField label="別名" messages={fieldErrors.aliases}>
|
||||
<FormField label={label ('aliases')} messages={fieldErrors.aliases}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
{/* TODO: 補完に対応させる */}
|
||||
@@ -161,7 +164,7 @@ const TagDetailPage: FC = () => {
|
||||
</FormField>
|
||||
|
||||
{/* 上位タグ */}
|
||||
<FormField label="上位タグ" messages={fieldErrors.parentTags}>
|
||||
<FormField label={label ('parents')} messages={fieldErrors.parentTags}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
{/* TODO: 補完に対応させる */}
|
||||
@@ -176,7 +179,7 @@ const TagDetailPage: FC = () => {
|
||||
</>)}
|
||||
</FormField>
|
||||
|
||||
<FormField label="廃止済" messages={fieldErrors.deprecated}>
|
||||
<FormField label={label ('deprecated')} messages={fieldErrors.deprecated}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -195,7 +198,7 @@ const TagDetailPage: FC = () => {
|
||||
(disabled
|
||||
? 'text-gray-300 bg-gray-500'
|
||||
: 'text-white bg-blue-500'))}>
|
||||
更新
|
||||
{label ('shared:actions.update')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -45,7 +45,7 @@ const WikiSearchPage: FC = () => {
|
||||
</Helmet>
|
||||
|
||||
<div className="max-w-xl">
|
||||
<PageTitle>Wiki</PageTitle>
|
||||
<PageTitle>{label ('navigation:wiki')}</PageTitle>
|
||||
<form onSubmit={handleSearch} className="space-y-2">
|
||||
{/* タイトル */}
|
||||
<FormField label={label ('labels.title')}>
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする