アイヌ語追加 (#428) (#433)

Reviewed-on: #433
Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
2026-10-20 20:44:14 +09:00
committed by みてるぞ
コミット 084a090cab
294個のファイルの変更、13978行の追加、3461行の削除
+7 -1
ファイルの表示
@@ -1,4 +1,7 @@
import { useTranslation } from 'react-i18next'
import nikumaru from '@/assets/fonts/nikumaru.otf'
import { translateUIMessage } from '@/lib/i18n'
import type { FC } from 'react'
@@ -8,6 +11,8 @@ const COLUMN_COUNT = 12
const DevModeWatermark: FC = () => {
const { i18n } = useTranslation ('shared')
const text = translateUIMessage ('labels.development', 'shared', i18n.language)
return (
<div
aria-hidden="true"
@@ -29,13 +34,14 @@ const DevModeWatermark: FC = () => {
{Array.from ({ length: COLUMN_COUNT }, (_, columnIndex) => (
<span
key={columnIndex}
lang={text.language}
className={
'whitespace-nowrap text-3xl font-bold '
+ 'tracking-[0.3em] text-neutral-950/5 '
+ 'dark:text-white/10'
}
style={{ fontFamily: 'Nikumaru' }}>
開発モード
{text.text}
</span>))}
</div>))}
</div>
+5 -4
ファイルの表示
@@ -1,5 +1,6 @@
import { render, screen } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it } from 'vitest'
import ErrorScreen from '@/components/ErrorScreen'
@@ -12,9 +13,9 @@ describe ('ErrorScreen', () => {
[503, '鯖死んでるよ(泣)'],
]) ('renders status %s', (status, message) => {
render (
<HelmetProvider>
<MemoryRouter><HelmetProvider>
<ErrorScreen status={status}/>
</HelmetProvider>,
</HelmetProvider></MemoryRouter>,
)
expect (screen.getByText (String (status))).toBeInTheDocument ()
@@ -24,9 +25,9 @@ describe ('ErrorScreen', () => {
it ('throws for unsupported statuses', () => {
expect (() => render (
<HelmetProvider>
<MemoryRouter><HelmetProvider>
<ErrorScreen status={418}/>
</HelmetProvider>,
</HelmetProvider></MemoryRouter>,
)).toThrow ()
})
})
+27 -26
ファイルの表示
@@ -1,9 +1,9 @@
import { Helmet } from 'react-helmet-async'
import nikumaru from '@/assets/fonts/nikumaru.otf'
import errorImg from '@/assets/images/not-found.gif'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { useUITranslation } from '@/lib/useUITranslation'
import { currentRenderContext } from '@/lib/renderContext'
import type { FC } from 'react'
@@ -11,17 +11,19 @@ type Props = { status: number }
const ErrorScreen: FC<Props> = ({ status }) => {
const [message, rightMsg, leftMsg]: [string, string, string] = (() => {
const context = currentRenderContext ()
if (context != null)
context.status = status
const { message: translate, label } = useUITranslation ('errors')
const [message]: [string, string, string] = (() => {
switch (status)
{
case 403:
return ['権限ないよ(笑)', '帰れ!', '帰れ!']
case 404:
return ['ページないよ(笑)', '帰れ!', '帰れ!']
case 500:
return ['鯖でエラー出たって(嘲笑)', '管理人に', '聯絡を!']
case 503:
return ['鯖死んでるよ(泣)', '管理人に', '聯絡を!']
return [translate (`${ status }.message`).text,
translate (`${ status }.right`).text, translate (`${ status }.left`).text]
default:
throw new Error
}
@@ -29,35 +31,34 @@ const ErrorScreen: FC<Props> = ({ status }) => {
const title = message.replaceAll ('(', ' (').replaceAll (')', ')')
const style = document.createElement ('style')
style.innerHTML = `
@font-face
{
font-family: 'Nikumaru';
src: url(${ nikumaru }) format('opentype');
}`
document.head.appendChild (style)
return (
<MainArea>
<Helmet>
<PageHead title={{ text: title,
language: translate (`${status}.message`).language }}>
<meta name="robots" content="noindex"/>
<title>{title} | {SITE_TITLE}</title>
</Helmet>
<style>{`@font-face { font-family: 'Nikumaru';
src: url(${nikumaru}) format('opentype'); }`}</style>
</PageHead>
<div className="text-6xl font-bold text-transparent
bg-[linear-gradient(90deg,#ff0000,#ff8800,#ffff00,#00ff00,#00ffff,#0000ff,#ff00ff,#ff0000)]
bg-clip-text bg-[length:200%_100%] animate-rainbow-scroll drop-shadow-[0_0_6px_black]
space-y-2 sm:space-y-4 md:space-y-6 text-center flex flex-col justify-center items-center"
style={{ fontFamily: 'Nikumaru' }}>
<p>{status}</p>
<div className="flex flex-row space-x-1 sm:space-x-2 md:space-x-4">
<p style={{ writingMode: 'vertical-rl' }}>{leftMsg}</p>
<img className="max-w-[70vw]" src={errorImg} alt="逃げたギター"/>
<p style={{ writingMode: 'vertical-rl' }}>{rightMsg}</p>
<div className="flex w-full min-w-0 items-center justify-center
space-x-1 sm:space-x-2 md:space-x-4">
<p className="shrink-0" style={{ writingMode: 'vertical-rl' }}>
{label (`${ status }.left`)}
</p>
<img className="aspect-video h-auto min-w-0 max-w-[70vw] object-contain"
width={640} height={360} src={errorImg} alt={translate ('imageAlt').text}/>
<p className="shrink-0" style={{ writingMode: 'vertical-rl' }}>
{label (`${ status }.right`)}
</p>
</div>
<p className="mr-[-.5em]">{message}</p>
<p className="mr-[-.5em]">{label (`${ status }.message`)}</p>
</div>
</MainArea>)
}
export default ErrorScreen
export default ErrorScreen
+47
ファイルの表示
@@ -0,0 +1,47 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import LocalisedText from '@/components/LocalisedText'
describe ('LocalisedText', () => {
it.each ([
['ネコ チャペ', ['ネコ', 'チャペ']],
['ネコ、チャペ', ['ネコ、', 'チャペ']],
['著作者人格権、商標権、肖像権、パブリシティー権',
['著作者人格権、', '商標権、', '肖像権、', 'パブリシティー権']],
['ネコ、チャペ。', ['ネコ、', 'チャペ。']],
['ネコ、「チャペ」。', ['ネコ、', '「チャペ」。']],
['ネコ,、」チャペ', ['ネコ,、」', 'チャペ']]]) (
'adds word boundaries without changing %s', (text, words) => {
const { container } = render (<LocalisedText language="ain">{text}</LocalisedText>)
const wrapper = container.firstElementChild!
expect (wrapper).toHaveAttribute ('lang', 'ain-Kana')
expect (wrapper.textContent).toBe (text)
expect (Array.from (wrapper.querySelectorAll ('.ainu-ui-word'), node => node.textContent))
.toEqual (words)
expect (wrapper.querySelectorAll ('wbr')).toHaveLength (words.length - 1)
})
it ('preserves Japanese fallback and links and code inside an Ainu paragraph', () => {
const { container } = render (
<LocalisedText language="ain">
<span lang="ja">日本語、本文</span>
<a href="https://example.com">ネコ チャペ</a>
<code>ネコ チャペ</code>
<strong>ネコ チャペ</strong>
</LocalisedText>)
expect (container.querySelector ('[lang="ja"] .ainu-ui-word')).toBeNull ()
expect (container.querySelector ('a .ainu-ui-word')).toBeNull ()
expect (container.querySelector ('code .ainu-ui-word')).toBeNull ()
expect (container.querySelectorAll ('strong .ainu-ui-word')).toHaveLength (2)
})
it ('does not apply Ainu word splitting to Japanese text', () => {
const { container } = render (
<LocalisedText language="ja">ネコ、チャペ。</LocalisedText>)
expect (container.firstElementChild).toHaveAttribute ('lang', 'ja')
expect (container.querySelector ('.ainu-ui-word')).toBeNull ()
expect (container.textContent).toBe ('ネコ、チャペ。')
})
})
+89
ファイルの表示
@@ -0,0 +1,89 @@
import { Children, cloneElement, Fragment, isValidElement } from 'react'
import { cn } from '@/lib/utils'
import type { ReactNode } from 'react'
export const formatUIString = (text: string, language: string): string =>
(language === 'ain' || language === 'ain-Kana')
? text.replace (/[ \t\r\n]+/g, '\u3000')
: text
const splitAinuText = (text: string): string[] => {
const parts: string[] = []
let part = ''
let boundary = false
for (const character of text)
{
// Keep punctuation runs and closing brackets/quotes with the preceding word.
if (boundary && !/[\u3000、,,。.\p{Pe}\p{Pf}]/u.test (character))
{
parts.push (part)
part = ''
boundary = false
}
part += character
if (/[\u3000、,,。.]/u.test (character))
boundary = true
}
if (part !== '')
parts.push (part)
return parts
}
export const localiseUIChildren = (children: ReactNode, language: string): ReactNode => {
if (language !== 'ain' && language !== 'ain-Kana')
return children
return Children.map (children, child => {
if (typeof child === 'string')
{
const parts = splitAinuText (formatUIString (child, language))
return parts.map ((part, index) => {
const spaces = part.match (/\u3000+$/u)?.[0] ?? ''
const word = part.slice (0, part.length - spaces.length)
return (
<Fragment key={index}>
{word !== '' && <span className="ainu-ui-word">{word}</span>}
{spaces}
{index < parts.length - 1 && <wbr/>}
</Fragment>)
})
}
if (isValidElement<{ children?: ReactNode; lang?: string }> (child))
{
// Only traverse inline formatting owned by the renderer. Components,
// links and code retain their own children and behaviour.
const inline = child.type === Fragment
|| (typeof child.type === 'string'
&& ['span', 'em', 'strong', 'del', 's', 'b', 'i', 'u',
'small', 'mark', 'sup', 'sub'].includes (child.type))
if (!inline
|| (child.props.lang != null && !(child.props.lang.startsWith ('ain'))))
return child
return cloneElement (child, { }, localiseUIChildren (child.props.children, language))
}
return child
})
}
const LocalisedText = (
{ children, language, className }: {
children: ReactNode
language: string
className?: string },
) => (
<span lang={language === 'ain' ? 'ain-Kana' : language}
className={cn ((language === 'ain' || language === 'ain-Kana') && 'ainu-ui-text',
className)}>
{localiseUIChildren (children, language)}
</span>)
export default LocalisedText
+40 -30
ファイルの表示
@@ -1,19 +1,20 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import SidebarComponent from '@/components/layout/SidebarComponent'
import TagLink from '@/components/TagLink'
import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CSSProperties, Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types'
const FILTERS: MaterialFilter[] = ['missing', 'present', 'any']
const FILTER_LABELS: Record<MaterialFilter, string> = { present: '有', missing: '無', any: '全' }
const px = (value: string): number => {
@@ -83,7 +84,7 @@ const tagSelectionShellClass = (selected: boolean): string =>
const updateMaterialFilterQuery = (
pathname: string,
locationSearch: string,
navigate: ReturnType<typeof useNavigate>,
navigate: ReturnType<typeof useLocaleNavigate>,
materialFilter: MaterialFilter,
) => {
const qs = new URLSearchParams (locationSearch)
@@ -95,24 +96,27 @@ const updateMaterialFilterQuery = (
const MaterialFilterButtons: FC<{ materialFilter: MaterialFilter
onChange: (materialFilter: MaterialFilter) => void }> = (
{ materialFilter, onChange },
) => (
<div className="flex flex-wrap gap-2 justify-end md:justify-start flex-center">
<label className="my-auto text-sm font-bold">素材:</label>
{FILTERS.map (value => (
<button
key={value}
type="button"
onClick={() => onChange (value)}
className={cn (
'rounded-full border px-3 py-1 text-sm',
(materialFilter === value
? ['border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
'dark:bg-sky-950 dark:text-sky-100']
: ['border-neutral-300 bg-white text-neutral-700 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-200']))}>
{FILTER_LABELS[value]}
</button>))}
</div>)
) => {
const { label } = useUITranslation ('materials')
return (
<div className="flex flex-wrap gap-2 justify-end md:justify-start flex-center">
<label className="my-auto text-sm font-bold">{label ('sidebar.label')}</label>
{FILTERS.map (value => (
<button
key={value}
type="button"
onClick={() => onChange (value)}
className={cn (
'rounded-full border px-3 py-1 text-sm',
(materialFilter === value
? ['border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
'dark:bg-sky-950 dark:text-sky-100']
: ['border-neutral-300 bg-white text-neutral-700 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-200']))}>
{label (`sidebar.${value}`)}
</button>))}
</div>)
}
const MaterialTreeNode: FC<{
@@ -125,10 +129,11 @@ const MaterialTreeNode: FC<{
tag: MaterialSidebarTag
}> = ({ materialFilter, nestLevel = 0, onChildren, openTags, selectedTagId,
setOpenTags, tag }) => {
const locale = useLocale ()
const open = Boolean (openTags[tag.id])
const { data } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }, locale),
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }, locale),
enabled: open && tag.hasChildren && tag.children.length === 0})
useEffect (() => {
@@ -200,6 +205,7 @@ const MobileMaterialTreeNode: FC<{ depth?: number
tag,
},
) => {
const locale = useLocale ()
const open = Boolean (openTags[tag.id])
const tagColumnRef = useRef<HTMLDivElement | null> (null)
const chipRef = useRef<HTMLDivElement | null> (null)
@@ -210,8 +216,8 @@ const MobileMaterialTreeNode: FC<{ depth?: number
const [tagLinkInlineSizePx, setTagLinkInlineSizePx] = useState<number | null> (null)
const [childAvailableInlineSizePx, setChildAvailableInlineSizePx] = useState<number | null> (null)
const { data } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }, locale),
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }, locale),
enabled: open && tag.hasChildren && tag.children.length === 0})
useEffect (() => {
@@ -382,8 +388,10 @@ const MobileMaterialTreeNode: FC<{ depth?: number
const MaterialSidebar: FC = () => {
const { label } = useUITranslation ('materials')
const locale = useLocale ()
const location = useLocation ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const qs = new URLSearchParams (location.search)
const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'any')
const selectedTagId = Number (qs.get ('tag_id') ?? 0) || null
@@ -395,8 +403,8 @@ const MaterialSidebar: FC = () => {
useState<number | null> (null)
const { data: rootTags = [], isLoading, isError } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: null, materialFilter }),
queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter })})
queryKey: materialsKeys.tree ({ parentId: null, materialFilter }, locale),
queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter }, locale)})
useEffect (() => {
setDesktopTags (rootTags)
@@ -494,10 +502,12 @@ const MaterialSidebar: FC = () => {
materialFilter={materialFilter}
onChange={handleFilterChange}/>
{isLoading && (
<p className="text-sm text-neutral-500 dark:text-stone-400">読込中……</p>)}
<p className="text-sm text-neutral-500 dark:text-stone-400">
{label ('shared:messages.loadingEllipsis')}
</p>)}
{isError && (
<p className="text-sm text-red-600 dark:text-red-300">
タグ一覧の取得に失敗しました.
{label ('sidebar.error')}
</p>)}
{(!isLoading && !isError) && (
<ul>
+3 -3
ファイルの表示
@@ -3,9 +3,9 @@ import type { FC } from 'react'
const MenuSeparator: FC = () => (
<>
<span className="hidden md:inline flex items-center px-2">|</span>
<hr className="block md:hidden w-full opacity-25
<span className="top-nav-separator-vertical items-center px-2">|</span>
<hr className="top-nav-separator-horizontal w-full opacity-25
border-t border-black dark:border-white"/>
</>)
export default MenuSeparator
export default MenuSeparator
+10 -6
ファイルの表示
@@ -1,8 +1,10 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { textContentOf } from '@/test/text'
import PostEditForm from '@/components/PostEditForm'
import { buildPost, buildTag } from '@/test/factories'
import { renderWithProviders as render } from '@/test/render'
const postsApi = vi.hoisted (() => ({
updatePost: vi.fn (),
@@ -19,7 +21,8 @@ const toastApi = vi.hoisted (() => ({
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/lib/dialogues/useDialogue', () => ({
vi.mock ('@/lib/dialogues/useDialogue', async importOriginal => ({
...await importOriginal<typeof import ('@/lib/dialogues/useDialogue')> (),
default: () => ({
choice: vi.fn (),
}),
@@ -60,11 +63,12 @@ describe ('PostEditForm', () => {
parentPostIds: '3 4',
tags: 'general-tag',
}),
{ baseVersionNo: 4 },
{ baseVersionNo: 4 }, 'ja',
)
})
expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 }))
expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' })
expect (toastApi.toast).toHaveBeenCalledTimes (1)
expect (textContentOf (toastApi.toast.mock.calls[0][0].description)).toBe ('更新しました.')
})
it ('preserves duration while the video tag is temporarily removed', () => {
@@ -78,7 +82,7 @@ describe ('PostEditForm', () => {
render (<PostEditForm post={post} onSave={vi.fn ()}/>)
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
expect (screen.getByDisplayValue ('180.5'))
.toHaveValue ('180.5')
const tags = screen.getAllByRole ('textbox')[2]
@@ -88,7 +92,7 @@ describe ('PostEditForm', () => {
fireEvent.change (tags, {
target: { value: '動画 general-tag' },
})
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
expect (screen.getByDisplayValue ('180.5'))
.toHaveValue ('180.5')
})
+25 -14
ファイルの表示
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { useLocale } from '@/lib/useLocale'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError'
import PostDurationField from '@/components/posts/PostDurationField'
@@ -12,6 +13,7 @@ import useDialogue from '@/lib/dialogues/useDialogue'
import { updatePost } from '@/lib/posts'
import { msToTime } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, FormEvent } from 'react'
@@ -51,6 +53,8 @@ type Props = { post: Post
const PostEditForm: FC<Props> = ({ post, onSave }) => {
const locale = useLocale ()
const { label } = useUITranslation ('posts')
const [disabled, setDisabled] = useState (false)
const [duration, setDuration] = useState<string> (videoMsToDurationValue (post.videoMs))
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
@@ -61,7 +65,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
useState<string | null> (post.originalCreatedFrom)
const [parentPostIds, setParentPostIds] =
useState ((post.parentPosts ?? []).map (p => p.id).join (' '))
const [tags, setTags] = useState<string> ('')
const [tags, setTags] = useState<string> (() => tagsToStr (post.tags))
const [title, setTitle] = useState (post.title)
const videoFlg =
@@ -70,12 +74,15 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
const dialogue = useDialogue ()
const update = async (...args: Parameters<typeof updatePost>) => {
const update = async (
postData: Parameters<typeof updatePost>[0],
options: Parameters<typeof updatePost>[1],
) => {
clearValidationErrors ()
try
{
const data = await updatePost (...args)
const data = await updatePost (postData, options, locale)
onSave ({ ...post,
versionNo: data.versionNo,
title: data.title,
@@ -86,7 +93,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
siblingPosts: data.siblingPosts,
originalCreatedFrom: data.originalCreatedFrom,
originalCreatedBefore: data.originalCreatedBefore } as Post)
toast ({ description: '更新しました.' })
toast ({ description: label ('shared:messages.updated') })
}
catch (e)
{
@@ -96,24 +103,28 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
{
if (applyValidationError (e))
{
toast ({ description: '更新はできなかったよ……' })
toast ({ description: label ('edit.validationFailed') })
return
}
toast ({ title: '失敗……', description: '入力を確認してください.' })
toast ({ title: label ('shared:messages.failed'),
description: label ('shared:messages.checkInput') })
return
}
const action = await dialogue.choice ({
title: '競合が発生しました.',
title: label ('edit.conflictTitle'),
description: (
<div>
<p>ほかの耕作員が先に更新してゐます.</p>
<p>現在の変更をどう扱ひますか?</p>
<p>{label ('edit.conflictDescription')}</p>
<p>{label ('edit.conflictQuestion')}</p>
</div>),
choices: [...(response?.data?.mergeable ? [{ value: 'merge', label: '差分をマージ' }] : []),
{ value: 'overwrite', label: '強制上書き', variant: 'danger' }] })
cancelText: label ('shared:actions.cancel'),
closeLabel: label ('settings:actions.close'),
choices: [...(response?.data?.mergeable
? [{ value: 'merge', label: label ('edit.merge') }] : []),
{ value: 'overwrite', label: label ('edit.overwrite'), variant: 'danger' }] })
if (action === 'merge')
{
@@ -163,13 +174,13 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
<FieldError messages={baseErrors}/>
<PostTextField
label="タイトル"
label={label ('shared:labels.title')}
value={title ?? ''}
disabled={disabled}
onChange={setTitle}/>
<PostTextField
label="親投稿"
label={label ('shared:labels.parentPosts')}
value={parentPostIds}
disabled={disabled}
errors={fieldErrors.parentPostIds}
@@ -202,7 +213,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
{/* 送信 */}
<Button type="submit" disabled={disabled}>
更新
{label ('shared:actions.update')}
</Button>
</form>)
}
+5 -3
ファイルの表示
@@ -1,8 +1,9 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostEmbed from '@/components/PostEmbed'
import { buildPost } from '@/test/factories'
import { renderWithProviders as render } from '@/test/render'
const dialogue = vi.hoisted (() => ({
confirm: vi.fn (),
@@ -12,8 +13,9 @@ const nicoViewer = vi.hoisted (() => ({
props: vi.fn (),
}))
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => dialogue,
vi.mock ('@/lib/dialogues/useDialogue', async importOriginal => ({
...await importOriginal<typeof import ('@/lib/dialogues/useDialogue')> (),
default: () => dialogue,
}))
vi.mock ('@/components/NicoViewer', () => ({
+10 -8
ファイルの表示
@@ -3,9 +3,10 @@ import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer'
import TwitterEmbed from '@/components/TwitterEmbed'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import useDialogue from '@/lib/dialogues/useDialogue'
import { Button } from '@/components/ui/button'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, RefObject } from 'react'
@@ -36,6 +37,7 @@ const PostEmbed: FC<Props> = (
onError },
) => {
const dialogue = useDialogue ()
const { label } = useUITranslation ('posts')
const behaviourSettings = useClientBehaviourSettings ()
const embedAutoLoadMode = behaviourSettings.embedAutoLoad ?? 'auto'
const [manualLoadRequested, setManualLoadRequested] = useState (false)
@@ -135,7 +137,7 @@ const PostEmbed: FC<Props> = (
const externalLink = (
<a href={post.url} target="_blank" rel="noreferrer">
外部ページを開く
{label ('embed.openExternal')}
</a>)
const manualLoadControl = (
@@ -144,7 +146,7 @@ const PostEmbed: FC<Props> = (
type="button"
variant="outline"
onClick={() => setManualLoadRequested (true)}>
埋め込みを読込
{label ('embed.load')}
</Button>
{externalLink}
</div>)
@@ -239,15 +241,15 @@ const PostEmbed: FC<Props> = (
e.preventDefault ()
setFramed (await dialogue.confirm ({
title: '未確認の外部ページを表示します。',
title: label ('embed.confirmTitle'),
description: (
<div>
<p>悪意のあるスクリプトが実行される可能性があります。</p>
<p>表示しますか?</p>
<p>{label ('embed.risk')}</p>
<p>{label ('embed.question')}</p>
</div>),
confirmText: '表示' }))
confirmText: label ('shared:actions.show') }))
}}>
外部ページを表示
{label ('embed.showExternal')}
</a>)}
</div>)}
</>)
+2 -1
ファイルの表示
@@ -26,7 +26,8 @@ describe ('PostFormTagsArea', () => {
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ params: { q: '虹', nico: '0' } },
{ locale: 'ja', params: { q: '虹', nico: '0' },
signal: expect.any (AbortSignal) },
)
})
expect (setTags).toHaveBeenCalledWith ('虹夏')
+54 -14
ファイルの表示
@@ -1,13 +1,15 @@
// TODO: TagSearch と共通化する.
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import TagSearchBox from '@/components/TagSearchBox'
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'
@@ -34,19 +36,29 @@ 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 }) => {
const { label } = useUITranslation ('navigation')
const locale = useLocale ()
const ref = useRef<HTMLTextAreaElement> (null)
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
const [focused, setFocused] = useState (false)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [request, setRequest] = useState ({ query: '' })
const [result, setResult] = useState<{
locale: string
request: { query: string }
tags: Tag[] }> ({ locale, request, tags: [] })
const currentToken = getTokenAt (tags, Math.min (bounds.start, tags.length)).token
const suggestions = (result.locale === locale && result.request === request
&& request.query === currentToken) ? result.tags : []
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
const handleTagSelect = (tag: Tag) => {
setSuggestionsVsbl (false)
setRequest ({ query: '' })
const textarea = ref.current!
const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name + ' ')
setTags (newValue)
@@ -58,8 +70,10 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
})
}
const recompute = async (pos: number, v: string = tags) => {
const recompute = (pos: number, v: string = tags) => {
const { start, end, token } = getTokenAt (v, pos)
setRequest ({ query: token })
setSuggestionsVsbl (token.trim () !== '')
if (!(token.trim ()))
{
setSuggestionsVsbl (false)
@@ -67,17 +81,43 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
}
setBounds ({ start, end })
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: {
q: token,
nico: '0' } })
const nextSuggestions = data.filter (t => t.postCount > 0)
setSuggestions (nextSuggestions)
setSuggestionsVsbl (nextSuggestions.length > 0)
}
useEffect (() => {
if (request.query.trim () === '')
return
const controller = new AbortController ()
let active = true
void (async () => {
try
{
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
locale, signal: controller.signal, params: { q: request.query, nico: '0' } })
if (active)
{
setResult ({ locale, request,
tags: tags.filter (tag => tag.postCount > 0) })
}
}
catch
{
if (active)
setResult ({ locale, request, tags: [] })
}
}) ()
return () => {
active = false
controller.abort ()
}
}, [request, locale])
useEffect (() => {
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}, [locale])
return (
<FormField className="relative w-full" label="タグ" messages={errors}>
<FormField className="relative w-full" label={label ('tags')} messages={errors}>
{({ describedBy, invalid }) => (
<>
<TextArea
@@ -89,7 +129,7 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
onChange={ev => setTags (ev.target.value)}
onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => {
const pos = (ev.target as HTMLTextAreaElement).selectionStart
await recompute (pos)
await recompute (pos, (ev.target as HTMLTextAreaElement).value)
}}
onFocus={() => setFocused (true)}
onBlur={() => {
+2 -2
ファイルの表示
@@ -26,11 +26,11 @@ describe ('PostList', () => {
expect (screen.getByRole ('link', { name: 'First' })).toHaveAttribute (
'href',
'/posts/1',
'/ja/posts/1',
)
expect (
screen.getByRole ('link', { name: 'https://example.com/second' }),
).toHaveAttribute ('href', '/posts/2')
).toHaveAttribute ('href', '/ja/posts/2')
})
it ('calls the optional click handler', () => {
+3 -1
ファイルの表示
@@ -3,6 +3,7 @@ import { useRef } from 'react'
import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import { useUITranslation } from '@/lib/useUITranslation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
@@ -16,6 +17,7 @@ type Props = { posts: Post[]
const PostList: FC<Props> = ({ posts, onClick }) => {
const { label } = useUITranslation ('shared')
const location = useLocation ()
const behaviourSettings = useClientBehaviourSettings ()
const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal'
@@ -75,7 +77,7 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
? (
<div className="flex h-full w-full items-center justify-center
bg-muted text-center text-xs text-muted-foreground">
サムネイルなし
{label ('labels.noThumbnail')}
</div>)
: (
<img
+2 -1
ファイルの表示
@@ -1,7 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import { renderWithProviders as render } from '@/test/render'
describe ('PostOriginalCreatedTimeField', () => {
it ('updates from and before values', () => {
+62 -61
ファイルの表示
@@ -1,8 +1,9 @@
import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
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
@@ -10,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> = (
@@ -19,71 +20,71 @@ const PostOriginalCreatedTimeField: FC<Props> = (
setOriginalCreatedFrom,
originalCreatedBefore,
setOriginalCreatedBefore,
errors }: Props) => {
errors }: Props,
) => {
const { label } = useUITranslation ('shared')
return (
<FormField label="オリジナルの作成日時" messages={errors}>
<FormField label={label ('labels.originalCreatedAt')} messages={errors}>
{({ describedBy, invalid }) => (
<>
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled ?? false}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return
<div className="my-1 flex min-w-0 flex-wrap items-center gap-2">
<DateTimeField
className="min-w-0 max-w-full"
disabled={disabled ?? false}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return
const d = new Date (v)
if (d.getMinutes () === 0 && d.getHours () === 0)
d.setDate (d.getDate () + 1)
else
d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ()))
}}/>
以降
</div>
<div>
<Button
type="button"
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedFrom (null)
}}>
リセット
</Button>
</div>
const d = new Date (v)
if (d.getMinutes () === 0 && d.getHours () === 0)
d.setDate (d.getDate () + 1)
else
d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ()))
}}/>
<span className="ainu-ui-label shrink-0 whitespace-nowrap">
{label ('labels.since')}
</span>
<Button
type="button"
className="ainu-ui-label shrink-0 rounded bg-gray-600 text-white
dark:bg-gray-600 dark:text-white"
disabled={disabled}
onClick={() => {
setOriginalCreatedFrom (null)
}}>
{label ('actions.reset')}
</Button>
</div>
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
より前
</div>
<div>
<Button
type="button"
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedBefore (null)
}}>
リセット
</Button>
</div>
<div className="my-1 flex min-w-0 flex-wrap items-center gap-2">
<DateTimeField
className="min-w-0 max-w-full"
disabled={disabled}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
<span className="ainu-ui-label shrink-0 whitespace-nowrap">
{label ('labels.before')}
</span>
<Button
type="button"
className="ainu-ui-label shrink-0 rounded bg-gray-600 text-white
dark:bg-gray-600 dark:text-white"
disabled={disabled}
onClick={() => {
setOriginalCreatedBefore (null)
}}>
{label ('actions.reset')}
</Button>
</div>
</>)}
</FormField>)
+13 -6
ファイルの表示
@@ -1,12 +1,15 @@
import { useQueryClient } from '@tanstack/react-query'
import { forwardRef, useMemo } from 'react'
import { flushSync } from 'react-dom'
import { createPath, useNavigate } from 'react-router-dom'
import { createPath } from 'react-router-dom'
import { localiseTo } from '@/lib/locales'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import { currentRenderContext } from '@/lib/renderContext'
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
import type { To } from 'react-router-dom'
@@ -32,16 +35,20 @@ export default forwardRef<HTMLAnchorElement, Props> (({
if ('onClick' in rest)
delete rest['onClick']
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const locale = useLocale ()
const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings ()
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
const path = useMemo (
() => typeof to === 'string' ? to : createPath (to),
[to],
() => {
const localised = localiseTo (to, locale)
return typeof localised === 'string' ? localised : createPath (localised)
},
[to, locale],
)
const url = useMemo (() => {
return (new URL (path, window.location.origin)).toString ()
return (new URL (path, currentRenderContext ()?.origin ?? 'http://localhost')).toString ()
}, [path])
const setOverlay = useOverlayStore (s => s.setActive)
@@ -98,7 +105,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
if (!(ok) && cancelOnError)
return
navigate (to, { replace, ...(state && { state }) })
navigate (path, { replace, ...(state && { state }) })
}
catch (ex)
{
+11 -3
ファイルの表示
@@ -1,6 +1,10 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { create } from 'zustand'
import LocalisedText from '@/components/LocalisedText'
import { translateUIMessage } from '@/lib/i18n'
import type { FC } from 'react'
type OverlayStore = {
@@ -14,6 +18,9 @@ export const useOverlayStore = create<OverlayStore> (set => ({
const RouteBlockerOverlay: FC = () => {
const { i18n } = useTranslation ('common')
const loading = translateUIMessage ('loading', 'common', i18n.language)
const loadingLabel = translateUIMessage ('loadingLabel', 'common', i18n.language)
const active = useOverlayStore (s => s.active)
useEffect (() => {
@@ -35,14 +42,15 @@ const RouteBlockerOverlay: FC = () => {
return (
<div
role="progressbar"
aria-label="Loading"
aria-label={loadingLabel.text}
lang={loadingLabel.language}
className="fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm pointer-events-auto">
<div className="absolute inset-0 flex items-center justify-center">
<div className="rounded-2xl bg-black/60 text-white px-6 py-3 text-sm">
Loading...
<LocalisedText language={loading.language}>{loading.text}</LocalisedText>
</div>
</div>
</div>)
}
export default RouteBlockerOverlay
export default RouteBlockerOverlay
+2 -2
ファイルの表示
@@ -17,7 +17,7 @@ describe ('SortHeader', () => {
expect (screen.getByRole ('link', { name: 'タイトル ▲' })).toHaveAttribute (
'href',
'/posts?tags=x&page=1&order=title%3Adesc',
'/ja/posts?tags=x&page=1&order=title%3Adesc',
)
})
@@ -33,7 +33,7 @@ describe ('SortHeader', () => {
expect (screen.getByRole ('link', { name: '更新' })).toHaveAttribute (
'href',
'/posts?page=1&order=updated_at%3Adesc',
'/ja/posts?page=1&order=updated_at%3Adesc',
)
})
})
+5 -3
ファイルの表示
@@ -2,10 +2,12 @@ import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import type { ReactNode } from 'react'
export default <T extends string,>({ by, label, currentOrder, defaultDirection }: {
by: T
label: string
label: ReactNode
currentOrder: `${ T }:${ 'asc' | 'desc' }`
defaultDirection: Record<T, 'asc' | 'desc'> }) => {
const [fld, dir] = currentOrder.split (':')
@@ -23,9 +25,9 @@ export default <T extends string,>({ by, label, currentOrder, defaultDirection }
<PrefetchLink
className="text-inherit visited:text-inherit hover:text-inherit"
to={`${ location.pathname }?${ qs.toString () }`}>
<span className="font-bold">
<span className="inline-flex w-max items-baseline gap-1 whitespace-nowrap font-bold">
{label}
{by === fld && (dir === 'asc' ? ' ▲' : ' ▼')}
{by === fld && <span className="shrink-0">{dir === 'asc' ? '▲' : '▼'}</span>}
</span>
</PrefetchLink>)
}
+43 -25
ファイルの表示
@@ -12,6 +12,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useLocale } from '@/lib/useLocale'
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink'
@@ -25,8 +26,11 @@ 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'
import type { FC, MutableRefObject, ReactNode } from 'react'
@@ -180,8 +184,10 @@ const buildFlatTagByCategory = (
const changeCategory = async (
tagId: number,
category: Category): Promise<void> => {
await apiPatch (`/tags/${ tagId }`, { category })
category: Category,
locale: LocaleCode = 'ja',
): Promise<void> => {
await apiPatch (`/tags/${ tagId }`, { category }, { locale })
}
@@ -227,6 +233,8 @@ type ActiveTagDrag = {
const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
const locale = useLocale ()
const { label } = useUITranslation ('tags')
sp = Boolean (sp)
const qc = useQueryClient ()
@@ -271,7 +279,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
useSensor (TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } }))
const reloadTags = async (): Promise<void> => {
setTags (buildTagByCategory (await apiGet<Post> (`/posts/${ post.id }`)))
setTags (buildTagByCategory (await apiGet<Post> (`/posts/${ post.id }`, { locale })))
qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root })
}
@@ -314,14 +322,15 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
return
if (fromParentId != null)
await apiDelete (`/tags/${ fromParentId }/children/${ childId }`)
await apiDelete (`/tags/${ fromParentId }/children/${ childId }`, { locale })
await apiPost (`/tags/${ parentId }/children/${ childId }`, { })
await apiPost (`/tags/${ parentId }/children/${ childId }`, { }, { locale })
await reloadTags ()
toast ({
title: '上位タグ対応追加',
description: `《${ child?.name }》を《${ parent?.name }》の子タグに設定しました.` })
title: label ('relations.added'),
description: label ('relations.addedDescription', {
child: child.name, parent: parent.name }) })
break
}
@@ -333,20 +342,21 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
return
if (child.category !== cat)
await changeCategory (childId, cat)
await changeCategory (childId, cat, locale)
if (fromParentId != null)
await apiDelete (`/tags/${ fromParentId }/children/${ childId }`)
await apiDelete (`/tags/${ fromParentId }/children/${ childId }`, { locale })
const fromParent = fromParentId == null ? null : findTag (tags, fromParentId)
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
}
@@ -354,7 +364,8 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
}
catch
{
toast ({ title: '上位タグ対応失敗', description: '独裁者である必要があります.' })
toast ({ title: label ('relations.failed'),
description: label ('relations.permissionRequired') })
}
finally
{
@@ -468,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}
@@ -492,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>
+2 -2
ファイルの表示
@@ -13,7 +13,7 @@ describe ('TagLink', () => {
expect (screen.getByRole ('link', { name: '虹 夏' })).toHaveAttribute (
'href',
'/posts?tags=%E8%99%B9+%E5%A4%8F',
'/ja/posts?tags=%E8%99%B9+%E5%A4%8F',
)
expect (screen.getByText ('4')).toBeInTheDocument ()
})
@@ -40,7 +40,7 @@ describe ('TagLink', () => {
expect (screen.getByRole ('link', { name: '?' })).toHaveAttribute (
'href',
'/wiki/a%2Fb',
'/ja/wiki/a%2Fb',
)
})
+11 -3
ファイルの表示
@@ -1,5 +1,6 @@
import PrefetchLink from '@/components/PrefetchLink'
import ResponsiveMarqueeText from '@/components/ResponsiveMarqueeText'
import { useUITranslation } from '@/lib/useUITranslation'
import { cn } from '@/lib/utils'
import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react'
@@ -67,6 +68,10 @@ const TagLink: FC<Props> = ({ tag,
style,
title,
...props }) => {
const { message } = useUITranslation ()
const missingMaterial = message ('tagLink.missingMaterial', { name: tag.name })
const missingDeerjikist = message ('tagLink.missingDeerjikist', { name: tag.name })
const missingWiki = message ('tagLink.missingWiki', { name: tag.name })
const colourStyle = {
'--tag-link-colour': `var(--tag-colour-${ tag.category })`,
'--tag-link-hover-colour': `var(--tag-colour-${ tag.category }-hover)`,
@@ -121,7 +126,8 @@ const TagLink: FC<Props> = ({ tag,
to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } 素材情報が存在しません.`}>
lang={missingMaterial.language}
title={missingMaterial.text}>
!
</PrefetchLink>)
: (
@@ -131,7 +137,8 @@ const TagLink: FC<Props> = ({ tag,
to={`/tags/${ tag.id }/deerjikists`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } に関する情報が存在しません.`}>
lang={missingDeerjikist.language}
title={missingDeerjikist.text}>
!
</PrefetchLink>)
: (
@@ -139,7 +146,8 @@ const TagLink: FC<Props> = ({ tag,
to={`/wiki/${ encodeURIComponent (tag.name) }`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } Wiki が存在しません.`}>
lang={missingWiki.language}
title={missingWiki.text}>
!
</PrefetchLink>)))}
</span>)}
+60 -19
ファイルの表示
@@ -1,8 +1,10 @@
// TODO: タグ入力系すべてに同様の処理あるため共通化する.
import { useEffect, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { apiGet } from '@/lib/api'
import { inputClass } from '@/lib/utils'
@@ -14,30 +16,64 @@ import type { Tag } from '@/types'
const TagSearch: FC = () => {
const { message } = useUITranslation ('shared')
const locale = useLocale ()
const location = useLocation ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const [activeIndex, setActiveIndex] = useState (-1)
const [search, setSearch] = useState ('')
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [request, setRequest] = useState ({ query: '' })
const [result, setResult] = useState<{
locale: string
request: { query: string }
tags: Tag[] }> ({ locale, request, tags: [] })
const suggestions = result.locale === locale && result.request === request
? result.tags : []
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
setSearch (ev.target.value)
const q = ev.target.value.trim ().split (' ').at (-1)
if (!(q))
{
setSuggestions ([])
return
}
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
setSuggestions (data.filter (t => t.postCount > 0))
if (suggestions.length > 0)
setSuggestionsVsbl (true)
const whenChanged = (ev: ChangeEvent<HTMLInputElement>) => {
const value = ev.target.value
const query = value.trim ().split (' ').at (-1) ?? ''
setSearch (value)
setRequest ({ query })
setActiveIndex (-1)
setSuggestionsVsbl (query !== '')
}
useEffect (() => {
if (request.query === '')
return
const controller = new AbortController ()
let active = true
void (async () => {
try
{
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
locale, signal: controller.signal, params: { q: request.query } })
if (active)
{
setResult ({ locale, request,
tags: tags.filter (tag => tag.postCount > 0) })
}
}
catch
{
if (active)
setResult ({ locale, request, tags: [] })
}
}) ()
return () => {
active = false
controller.abort ()
}
}, [request, locale])
useEffect (() => {
setActiveIndex (-1)
setSuggestionsVsbl (false)
}, [locale])
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
switch (ev.key)
{
@@ -81,6 +117,7 @@ const TagSearch: FC = () => {
match }))
.toString () }`)
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}
}
@@ -91,7 +128,8 @@ const TagSearch: FC = () => {
? ('not:' + tag.name)
: tag.name)
setSearch (parts.join (' ') + ' ')
setSuggestions ([])
setRequest ({ query: '' })
setSuggestionsVsbl (false)
setActiveIndex (-1)
}
@@ -100,13 +138,16 @@ const TagSearch: FC = () => {
const anyFlg = query.get ('match') === 'any'
const tagsQuery = `${ query.get ('tags') ?? '' }${ anyFlg ? ' type:or' : '' }`
setSearch (tagsQuery)
setRequest ({ query: '' })
setActiveIndex (-1)
setSuggestionsVsbl (false)
}, [location.search])
return (
<div className="relative w-full">
<input type="text"
data-shortcut-focus-search="true"
placeholder="タグ検索..."
placeholder={message ('labels.tagSearch').text}
value={search}
onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)}
+11 -7
ファイルの表示
@@ -1,7 +1,9 @@
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import TagLink from '@/components/TagLink'
import TagSearch from '@/components/TagSearch'
import SectionTitle from '@/components/common/SectionTitle'
@@ -22,7 +24,9 @@ type Props = { posts: Post[]
const TagSidebar: FC<Props> = ({ posts, onClick }) => {
const navigate = useNavigate ()
const { label } = useUITranslation ('shared')
const locale = useLocale ()
const navigate = useLocaleNavigate ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const animationsOff = animationMode === 'off'
@@ -69,7 +73,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
const TagBlock = (
<>
<SectionTitle>タグ</SectionTitle>
<SectionTitle>{label ('navigation:tags')}</SectionTitle>
<ul>
{CATEGORIES.flatMap (cat => cat in tags ? (
tags[cat].map (tag => (
@@ -88,7 +92,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
</motion.div>)}
</li>))) : [])}
</ul>
<SectionTitle>関聯</SectionTitle>
<SectionTitle>{label ('labels.relations')}</SectionTitle>
<a href="#"
onClick={ev => {
ev.preventDefault ()
@@ -96,7 +100,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
try
{
const data = await apiGet<Post> ('/posts/random',
{ params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '),
{ locale, params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '),
match: (anyFlg ? 'any' : 'all') } })
navigate (`/posts/${ data.id }`)
}
@@ -106,7 +110,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
}
}) ())
}}>
ランダム
{label ('actions.random')}
</a>
</>)
@@ -146,7 +150,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
ev.preventDefault ()
setTagsVsbl (v => !(v))
}}>
{tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'}
{tagsVsbl ? label ('labels.hideTags') : label ('labels.showTags')}
</a>
</SidebarComponent>)
}
+354 -162
ファイルの表示
@@ -1,12 +1,23 @@
import { useQuery } from '@tanstack/react-query'
import { AnimatePresence, motion } from 'framer-motion'
import { Globe } from 'lucide-react'
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import LocalisedText, { formatUIString } from '@/components/LocalisedText'
import Separator from '@/components/MenuSeparator'
import PrefetchLink from '@/components/PrefetchLink'
import TopNavUser from '@/components/TopNavUser'
import TranslatedText from '@/components/TranslatedText'
import { toast } from '@/components/ui/use-toast'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { isLocaleCode,
localeFromPath,
switchLocalePath,
unlocalisedPath } from '@/lib/locales'
import { uiMessage, uiMessageLanguage } from '@/lib/messages'
import { useUITranslation } from '@/lib/useUITranslation'
import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { canEditContent } from '@/lib/users'
@@ -17,20 +28,33 @@ import { fetchWikiPage } from '@/lib/wiki'
import type { FC, MouseEvent } from 'react'
import type { AvailableLocale, LocaleCode } from '@/lib/locales'
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
type Props = { user: User | null }
type Props = {
user: User | null
locales: AvailableLocale[]
resolveLocaleSwitch?: (locale: LocaleCode) => Promise<string> }
type NavDensity = 'auto' | 'comfortable' | 'compact'
export const menuOutline = (
{ tag, material, wikiId, user, pathName }: {
{ tag, material, wikiId, user, pathName: fullPathname }: {
tag?: Tag | null
material?: Material | null
wikiId: number | null
user: User | null
pathName: string },
): Menu => {
const pathName = unlocalisedPath (fullPathname)
const locale = localeFromPath (fullPathname) ?? 'ja'
const message = (
key: Parameters<typeof uiMessage>[1],
values?: Record<string, string | number>,
) => ({ name: uiMessage (locale, key, values),
nameLanguage: uiMessageLanguage (locale, key) })
const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0
const editable = canEditContent (user)
@@ -41,75 +65,89 @@ export const menuOutline = (
const materialFlg = /^\/materials\/\d+/.test (pathName)
const helpVsbl = /\/help\/((?!changes)[^\/?]+)/.test (pathName)
const helpVsbl = /\/help\/((?!changes)[^/?]+)/.test (pathName)
const helpIdentifier = pathName.split ('/')[2] ?? ''
return [
{ name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' },
{ name: '追加', to: '/posts/new', visible: editable },
{ name: '全体履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [
{ name: 'マスタ', to: '/tags' },
{ name: 'ニコニコ連携', to: '/tags/nico' },
{ name: '全体履歴', to: '/tags/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' },
{ ...message ('posts'), to: '/posts', subMenu: [
{ ...message ('list'), to: '/posts' },
{ ...message ('search'), to: '/posts/search' },
{ ...message ('add'), to: '/posts/new', visible: editable },
{ ...message ('allHistory'), to: '/posts/changes' },
{ ...message ('help'), to: '/wiki/ヘルプ:広場' }] },
{ ...message ('tags'), to: '/tags', subMenu: [
{ ...message ('master'), to: '/tags' },
{ ...message ('nicoLinks'), to: '/tags/nico' },
{ ...message ('allHistory'), to: '/tags/changes' },
{ ...message ('help'), to: '/wiki/ヘルプ:タグ' },
{ component: <Separator/>, visible: tagFlg },
{ name: `広場 (${ postCount || 0 })`,
{ ...message ('postsWithCount', { count: postCount }),
to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`,
visible: tagFlg },
{ name: '履歴', to: `/tags/changes?id=${ tag?.id }`,
{ ...message ('history'), to: `/tags/changes?id=${ tag?.id }`,
visible: tagFlg && tag?.category !== 'nico' }] },
{ name: '素材', to: '/materials', visible: true, subMenu: [
{ name: '一覧', to: '/materials' },
{ name: '追加', to: '/materials/new', visible: editable },
{ name: '抑止', to: '/materials/suppressions',
{ ...message ('materials'), to: '/materials', visible: true, subMenu: [
{ ...message ('list'), to: '/materials' },
{ ...message ('add'), to: '/materials/new', visible: editable },
{ ...message ('suppressions'), to: '/materials/suppressions',
visible: user?.role === 'admin' },
{ name: '全体履歴', to: '/materials/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材管理' },
{ ...message ('allHistory'), to: '/materials/changes' },
{ ...message ('help'), to: '/wiki/ヘルプ:素材管理' },
{ component: <Separator/>, visible: materialFlg },
{ name: `広場 (${ postCount || 0 })`,
{ ...message ('postsWithCount', { count: postCount }),
to: `/posts?tags=${ encodeURIComponent (material?.tag?.name ?? '') }`,
visible: materialFlg && Boolean (material?.tag) },
{ name: '履歴', to: `/materials/changes?material_id=${ material?.id }`,
{ ...message ('history'), to: `/materials/changes?material_id=${ material?.id }`,
visible: materialFlg }] },
{ name: 'Wiki', to: '/wiki', subMenu: [
{ name: '検索', to: '/wiki' },
{ name: '新規', to: '/wiki/new', visible: editable },
{ name: '全体履歴', to: '/wiki/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' },
{ ...message ('wiki'), to: '/wiki', subMenu: [
{ ...message ('search'), to: '/wiki' },
{ ...message ('create'), to: '/wiki/new', visible: editable },
{ ...message ('allHistory'), to: '/wiki/changes' },
{ ...message ('help'), to: '/wiki/ヘルプ:Wiki' },
{ component: <Separator/>, visible: wikiPageFlg },
{ name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`,
{ ...message ('postsWithCount', { count: postCount }), to: `/posts?tags=${ wikiTitle }`,
visible: wikiPageFlg },
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`,
{ ...message ('history'), to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`,
visible: wikiPageFlg && editable }] },
{ name: 'ヘルプ', to: '/help/home', base: '/help', subMenu: [
{ name: '検索', to: '/help' },
{ name: '全体履歴', to: '/help/changes' },
{ ...message ('help'), to: '/help/home', base: '/help', subMenu: [
{ ...message ('search'), to: '/help' },
{ ...message ('allHistory'), to: '/help/changes' },
{ component: <Separator/>, visible: helpVsbl },
{ name: '履歴',
{ ...message ('history'),
to: `/help/changes?identifier=${ helpIdentifier }`,
visible: helpVsbl }] },
{ name: 'おたのしみ', visible: false, subMenu: [
{ name: '上映会 (β)', to: '/theatres/1' },
{ name: 'グカネータ (β)', to: '/gekanator' }] },
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
{ name: '一覧', to: '/users', visible: false },
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] },
{ name: '法規', visible: false, subMenu: [
{ name: '利用規約', to: '/tos' }] }]
{ ...message ('entertainment'), visible: false, subMenu: [
{ ...message ('theatre'), to: '/theatres/1' },
{ ...message ('gekanator'), to: '/gekanator' }] },
{ ...message ('users'), to: '/users/settings', visible: false, subMenu: [
{ ...message ('list'), to: '/users', visible: false },
{ ...message ('currentUser'), to: `/users/${ user?.id }`, visible: false },
{ ...message ('settings'), to: '/users/settings', visible: Boolean (user) }] },
{ ...message ('legal'), visible: false, subMenu: [
{ ...message ('terms'), to: '/tos' }] }]
}
const TopNav: FC<Props> = ({ user }) => {
const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
const { message, label } = useUITranslation ('shared')
const locale = useLocale ()
const localeName = locales.find (entry => entry.code === locale)?.name
const navigate = useLocaleNavigate ()
const location = useLocation ()
const currentLocation = useRef (location)
currentLocation.current = location
const localeSwitchRequest = useRef (0)
const [switchingLocale, setSwitchingLocale] = useState (false)
useEffect (() => {
++localeSwitchRequest.current
setSwitchingLocale (false)
return () => { ++localeSwitchRequest.current }
}, [location.key])
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const animationsOff = animationMode === 'off'
const [layoutReady, setLayoutReady] = useState (false)
const animationsOff = animationMode === 'off' || !layoutReady
const reducedAnimations = animationMode === 'reduced'
const dirRef = useRef<(-1) | 1> (1)
@@ -117,23 +155,35 @@ const TopNav: FC<Props> = ({ user }) => {
const navRef = useRef<HTMLDivElement | null> (null)
const layoutRef = useRef<HTMLDivElement | null> (null)
const barRef = useRef<HTMLElement | null> (null)
const submenuContentRef = useRef<HTMLDivElement | null> (null)
const highlightTargetRef = useRef (-1)
const measure = (idx: number) => {
const nav = navRef.current
const el = itemsRef.current[idx < 0 ? visibleMenu.length : idx]
if (!(nav) || !(el))
{
setHL ({ left: 0, width: 0, visible: true })
return
}
if (nav == null || el == null || nav.offsetWidth === 0
|| layoutRef.current?.dataset.desktop !== 'true')
{
setHL (current => current.visible
? { left: 0, width: 0, visible: false } : current)
return
}
const navRect = nav.getBoundingClientRect ()
const elRect = el.getBoundingClientRect ()
setHL ({ left: elRect.left - navRect.left,
width: elRect.width,
visible: true })
// Layout offsets exclude Framer Motion's temporary projection transforms.
let left = 0
let node: HTMLElement | null = el
while (node != null && node !== nav)
{
left += node.offsetLeft
const parent: Element | null = node.offsetParent
node = parent instanceof HTMLElement ? parent : null
}
const width = node === nav ? el.offsetWidth : 0
const visible = width > 0
setHL (current =>
(current.left === left && current.width === width && current.visible === visible)
? current : { left, width, visible })
}
const [hl, setHL] = useState<{ left: number; width: number; visible: boolean }> ({
@@ -141,62 +191,67 @@ const TopNav: FC<Props> = ({ user }) => {
width: 0,
visible: false })
const [menuOpen, setMenuOpen] = useState (false)
const [desktop, setDesktop] = useState (false)
const menuToggleLabel = message (menuOpen ? 'dialogue.close' : 'navigation:menuToggle')
const [desktop, setDesktop] = useState<boolean | null> (null)
const [density, setDensity] = useState<NavDensity> ('auto')
const [moreVsbl, setMoreVsbl] = useState (false)
const [openItemIdx, setOpenItemIdx] = useState (-1)
const [submenuHeight, setSubmenuHeight] = useState (0)
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
const wikiIdStr = String (wikiId ?? '')
const { data: wikiPage } = useQuery ({
enabled: Boolean (wikiIdStr),
queryKey: wikiKeys.show (wikiIdStr, { }),
queryFn: () => fetchWikiPage (wikiIdStr, { }) })
queryKey: wikiKeys.show (wikiIdStr, { }, locale),
queryFn: () => fetchWikiPage (wikiIdStr, { }, locale) })
const tagFlg = /^\/tags\/\d+/.test (location.pathname)
const materialFlg = /^\/materials\/\d+/.test (location.pathname)
const pathname = unlocalisedPath (location.pathname)
const tagFlg = /^\/tags\/\d+/.test (pathname)
const materialFlg = /^\/materials\/\d+/.test (pathname)
const effectiveTitle = (((tagFlg || materialFlg)
? location.pathname.split ('/')[2]
? pathname.split ('/')[2]
: wikiPage?.title)
?? '')
const { data: tag } = useQuery ({
enabled: Boolean (effectiveTitle),
queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle) })
queryKey: tagsKeys.show (effectiveTitle, locale),
queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle, locale) })
const { data: material } = useQuery ({
enabled: Boolean (effectiveTitle),
queryKey: materialsKeys.show (effectiveTitle),
queryFn: () => fetchMaterial (effectiveTitle) })
queryKey: materialsKeys.show (effectiveTitle, locale),
queryFn: () => fetchMaterial (effectiveTitle, locale) })
const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname })
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
const menuLabels = visibleMenu.map (item => item.name).join ('\u0000')
const moreMenu = menu.filter (item =>
!(item.visible ?? true)
|| item.subMenu.filter (subItem => subItem.visible ?? true).length > 0)
const activeIdx =
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40)
visibleMenu.findIndex (item => pathname.startsWith (item.base || item.to))
highlightTargetRef.current = moreVsbl ? -1 : activeIdx
const activeSubmenuId = `submenu-${ menu.indexOf (visibleMenu[activeIdx]) }`
const topNavTransition =
animationsOff
? { duration: 0 }
: reducedAnimations
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
: (reducedAnimations
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const })
const opacityTransition =
animationsOff
? { duration: 0 }
: reducedAnimations
? { duration: .08 }
: { duration: .12 }
: (reducedAnimations
? { duration: .08 }
: { duration: .12 })
const highlightTransitionClass =
animationsOff
? undefined
: reducedAnimations
? 'transition-[transform,width] duration-75 ease-linear'
: 'transition-[transform,width] duration-200 ease-out'
: (reducedAnimations
? 'transition-[transform,width] duration-75 ease-linear'
: 'transition-[transform,width] duration-200 ease-out')
const prevActiveIdxRef = useRef<number> (activeIdx)
@@ -215,7 +270,7 @@ const TopNav: FC<Props> = ({ user }) => {
if (layout == null || bar == null || primary == null)
return
const userLink = bar.querySelector<HTMLElement> ('.top-nav-user-desktop')
const account = bar.querySelector<HTMLElement> ('.top-nav-account')
let raf = 0
let active = true
@@ -237,11 +292,16 @@ const TopNav: FC<Props> = ({ user }) => {
{
layout.setAttribute ('data-density', candidate)
const style = getComputedStyle (bar)
const required = primary.getBoundingClientRect ().width
+ (userLink?.getBoundingClientRect ().width ?? 0)
const primaryWidth = Math.max (
primary.getBoundingClientRect ().width, primary.scrollWidth)
const accountWidth =
account == null
? 0
: Math.max (account.getBoundingClientRect ().width, account.scrollWidth)
const required = primaryWidth + accountWidth
+ parseFloat (style.paddingLeft) + parseFloat (style.paddingRight)
+ (userLink == null ? 0 : parseFloat (style.columnGap))
if (required <= bar.clientWidth)
+ (account == null ? 0 : parseFloat (style.columnGap))
if (Math.ceil (required) <= bar.clientWidth)
return candidate
}
return null
@@ -269,8 +329,11 @@ const TopNav: FC<Props> = ({ user }) => {
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (schedule)
observer?.observe (bar)
observer?.observe (primary)
if (userLink != null)
observer?.observe (userLink)
for (const item of itemsRef.current)
if (item != null)
observer?.observe (item)
if (account != null)
observer?.observe (account)
update ()
addEventListener ('resize', schedule)
document.fonts?.addEventListener ('loadingdone', schedule)
@@ -283,22 +346,117 @@ const TopNav: FC<Props> = ({ user }) => {
removeEventListener ('resize', schedule)
document.fonts?.removeEventListener ('loadingdone', schedule)
}
}, [activeIdx, moreVsbl, desktop, density, user?.name])
}, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales, menuLabels])
useEffect (() => {
if (desktop == null || layoutReady)
return
let active = true
let frame = 0
void (document.fonts?.ready ?? Promise.resolve ()).then (() => {
if (!active)
return
frame = requestAnimationFrame (() => {
frame = requestAnimationFrame (() => {
if (active)
setLayoutReady (true)
})
})
})
return () => {
active = false
cancelAnimationFrame (frame)
}
}, [desktop, density, layoutReady])
useLayoutEffect (() => {
measure (moreVsbl ? -1 : activeIdx)
if (!(desktop))
setMoreVsbl (false)
}, [activeIdx, moreVsbl, desktop, density, locale, animationMode, menuLabels])
useEffect (() => {
const unsubscribe = WikiIdBus.subscribe (setWikiId)
return () => unsubscribe ()
}, [])
useLayoutEffect (() => {
const content = submenuContentRef.current
if (content == null)
return
const update = () => {
setSubmenuHeight (Math.ceil (content.getBoundingClientRect ().height))
}
const observer =
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (update)
observer?.observe (content)
update ()
addEventListener ('resize', update)
return () => {
observer?.disconnect ()
removeEventListener ('resize', update)
}
}, [activeIdx, moreVsbl, desktop, density, locale])
useEffect (() => {
setMenuOpen (false)
setOpenItemIdx (activeIdx)
}, [activeIdx, location])
const localeSelector = (
<label className="top-nav-menu-link relative flex h-8 shrink-0 items-center gap-1
rounded px-1 focus-within:ring-2">
<Globe aria-hidden="true" size={16}/>
<span className="top-nav-locale-name whitespace-nowrap text-sm">
<LocalisedText language={locale}>{localeName}</LocalisedText>
</span>
<select
aria-label={formatUIString (localeName ?? '', locale)}
value={locale}
disabled={switchingLocale}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
onChange={async event => {
const nextLocale = event.target.value
if (isLocaleCode (nextLocale)
&& locales.some (entry => entry.code === nextLocale))
{
const request = ++localeSwitchRequest.current
setSwitchingLocale (true)
try
{
const path = resolveLocaleSwitch == null
? switchLocalePath (location, nextLocale)
: await resolveLocaleSwitch (nextLocale)
if (request === localeSwitchRequest.current
&& currentLocation.current.key === location.key)
navigate (path)
}
catch
{
if (request === localeSwitchRequest.current)
toast ({ title: label ('common:fetchError') })
}
finally
{
if (request === localeSwitchRequest.current)
setSwitchingLocale (false)
}
}
}}>
{locales.map (entry => isLocaleCode (entry.code) && (
<option key={entry.code} value={entry.code}>
{formatUIString (entry.name, entry.code)}
</option>))}
</select>
</label>)
return (
<div ref={layoutRef} className="top-nav-layout w-full min-w-0 shrink-0"
data-desktop={desktop} data-density={density}>
<nav ref={barRef}
data-desktop={desktop ?? undefined} data-density={density}
data-layout-ready={layoutReady}>
<nav ref={barRef} lang={uiMessageLanguage (locale, 'posts')}
className="top-nav-root top-nav-bar flex justify-between items-center w-full">
<div className="top-nav-primary flex shrink-0 items-center h-12">
<PrefetchLink
@@ -307,7 +465,7 @@ const TopNav: FC<Props> = ({ user }) => {
onClick={() => {
scroll (0, 0)
}}>
ぼざクリ タグ広場
{label ('labels.brand')}
</PrefetchLink>
<div ref={navRef}
@@ -323,14 +481,15 @@ const TopNav: FC<Props> = ({ user }) => {
{visibleMenu.map ((item, i) => (
<motion.div
key={item.to}
initial={false}
className="shrink-0 whitespace-nowrap"
{...(animationsOff
? { }
: { layoutId: `menu-${ item.name }` })}
animate={{ opacity: moreVsbl ? 0 : 1 }}
layoutId={`menu-${ menu.indexOf (item) }`}
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
animate={layoutReady ? { opacity: moreVsbl ? 0 : 1 } : undefined}
transition={{ opacity: opacityTransition,
layout: topNavTransition }}
style={{ pointerEvents: moreVsbl ? 'none' : 'auto' }}
style={{ pointerEvents: moreVsbl ? 'none' : 'auto',
opacity: layoutReady ? undefined : (moreVsbl ? 0 : 1) }}
onMouseEnter={() => setMoreVsbl (false)}>
<PrefetchLink
to={item.to}
@@ -339,7 +498,9 @@ const TopNav: FC<Props> = ({ user }) => {
}}
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
(i === openItemIdx) && 'font-bold')}>
{item.name}
<LocalisedText language={item.nameLanguage ?? 'ja'}>
{item.name}
</LocalisedText>
</PrefetchLink>
</motion.div>))}
<PrefetchLink
@@ -355,58 +516,73 @@ const TopNav: FC<Props> = ({ user }) => {
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
'shrink-0 whitespace-nowrap',
(openItemIdx < 0 || moreVsbl) && 'font-bold')}>
その他 &raquo;
<TranslatedText messageKey="more"/>
</PrefetchLink>
</div>
</div>
<TopNavUser user={user}/>
<div className="top-nav-account ml-auto flex min-w-0 items-center gap-2">
<TopNavUser user={user}/>
{localeSelector}
</div>
<button
type="button"
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
ml-auto border-0 bg-transparent pr-4"
onClick={() => {
setMenuOpen (!(menuOpen))
}}>
{menuOpen ? '×' : 'Menu'}
</button>
<div className="top-nav-mobile-controls ml-auto flex shrink-0 items-center gap-2">
{menuOpen && localeSelector}
<button
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 ? '×' : label ('navigation:menuToggle')}
</button>
</div>
</nav>
<AnimatePresence initial={false}>
<motion.div
key="submenu-shell"
{...(animationsOff ? { }
: { layout: true })}
initial={false}
layout
className="top-nav-desktop-submenu relative z-20 shrink-0
overflow-hidden top-nav-submenu"
animate={{ height: submenuHeight }}
animate={{ height: desktop == null ? 'auto' : submenuHeight }}
onMouseLeave={() => {
if (moreVsbl)
setMoreVsbl (false)
}}
transition={{ layout: topNavTransition }}
transition={{ ...topNavTransition, layout: topNavTransition }}
onAnimationComplete={() => {
measure (moreVsbl ? -1 : activeIdx)
measure (highlightTargetRef.current)
}}
onLayoutAnimationComplete={() => {
measure (highlightTargetRef.current)
}}>
<div ref={submenuContentRef} className={cn (moreVsbl && 'top-nav-more-menu')}>
{moreVsbl
? (
moreMenu.map ((item, i) => (
<div key={i} className="relative h-[40px]">
<div className="absolute inset-0 flex items-center px-3
overflow-x-auto whitespace-nowrap">
<div key={i} className="contents">
<motion.div
transition={topNavTransition}
{...(animationsOff
? { }
: ((item.visible ?? true)
? { layoutId: `menu-${ item.name }` }
: { initial: { x: 40, y: -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 },
exit: { x: 40, y: -40, opacity: 0 } }))}
className="z-10 h-full flex shrink-0 items-center px-3 font-bold w-28">
<h2>{item.name}</h2>
{...((item.visible ?? true)
? { layoutId: `menu-${ menu.indexOf (item) }` }
: (animationsOff ? { }
: { initial: { x: 40, y: -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 },
exit: { x: 40, y: -40, opacity: 0 } }))}
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
className="z-10 min-w-0 px-3 py-2 font-bold">
<h2>
<LocalisedText language={item.nameLanguage ?? 'ja'}>
{item.name}
</LocalisedText>
</h2>
</motion.div>
<div className="top-nav-more-links flex min-w-0 flex-wrap items-center">
{item.subMenu
.filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => (
@@ -414,76 +590,77 @@ const TopNav: FC<Props> = ({ user }) => {
? (
<motion.div
key={`c-${ i }-${ j }`}
className="min-w-0 max-w-full"
transition={topNavTransition}
{...(animationsOff
? { }
: ((visibleMenu[activeIdx]?.name
=== item.name)
? { layoutId: `submenu-${ item.name }-${ j }` }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
{...(visibleMenu[activeIdx] === item
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
: (animationsOff ? { }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
{subItem.component}
</motion.div>)
: (
<motion.div
key={`l-${ i }-${ j }`}
className="min-w-0 max-w-full"
transition={topNavTransition}
{...(animationsOff
? { }
: ((visibleMenu[activeIdx]?.name
=== item.name)
? { layoutId: `submenu-${ item.name }-${ j }` }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
{...(visibleMenu[activeIdx] === item
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
: (animationsOff ? { }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
<PrefetchLink
to={subItem.to}
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
onClick={() => setMoreVsbl (false)}
className="top-nav-menu-link h-full flex items-center px-3">
{subItem.name}
className="top-nav-menu-link flex min-h-[40px] items-center px-3 py-2">
<LocalisedText language={subItem.nameLanguage ?? 'ja'}>
{subItem.name}
</LocalisedText>
</PrefetchLink>
</motion.div>)))}
</div>
</div>)))
: ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0
&& (
<div className="relative h-[40px]">
<div className="relative min-h-[40px]">
{animationsOff
? (
<div className="absolute inset-0 flex items-center px-3
overflow-x-auto whitespace-nowrap">
<div className="flex min-h-[40px] flex-wrap items-center px-3">
{(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true)
.map ((item, i) => (
'component' in item
? (
<div key={`c-${ i }`}>
<div key={`c-${ i }`} className="min-w-0 max-w-full">
{item.component}
</div>)
: (
<div key={`l-${ i }`}>
<div key={`l-${ i }`} className="min-w-0 max-w-full">
<PrefetchLink
to={item.to}
target={item.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="top-nav-menu-link h-full flex items-center px-3">
{item.name}
className="top-nav-menu-link flex min-h-[40px]
items-center px-3 py-2">
<LocalisedText language={item.nameLanguage ?? 'ja'}>
{item.name}
</LocalisedText>
</PrefetchLink>
</div>)))}
</div>)
: (
<AnimatePresence initial={false} custom={dir}>
<AnimatePresence initial={false} custom={dir} mode="popLayout">
<motion.div
key={activeIdx}
custom={dir}
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
centre: { y: 0, opacity: 1 },
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
className="absolute inset-0 flex items-center px-3
overflow-x-auto whitespace-nowrap"
className="relative flex min-h-[40px] flex-wrap items-center px-3"
initial="enter"
animate="centre"
exit="exit"
@@ -495,27 +672,33 @@ const TopNav: FC<Props> = ({ user }) => {
? (
<motion.div
key={`c-${ i }`}
className="min-w-0 max-w-full"
transition={{ layout: topNavTransition }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
layoutId={`${ activeSubmenuId }-${ i }`}>
{item.component}
</motion.div>)
: (
<motion.div
key={`l-${ i }`}
className="min-w-0 max-w-full"
transition={{ layout: topNavTransition }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
layoutId={`${ activeSubmenuId }-${ i }`}>
<PrefetchLink
to={item.to}
target={item.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="top-nav-menu-link h-full flex items-center px-3">
{item.name}
className="top-nav-menu-link flex min-h-[40px]
items-center px-3 py-2">
<LocalisedText language={item.nameLanguage ?? 'ja'}>
{item.name}
</LocalisedText>
</PrefetchLink>
</motion.div>)))}
</motion.div>
</AnimatePresence>)}
</div>))}
</div>
</motion.div>
</AnimatePresence>
@@ -540,7 +723,9 @@ const TopNav: FC<Props> = ({ user }) => {
setOpenItemIdx (i)
}
}}>
{item.name}
<LocalisedText language={item.nameLanguage ?? 'ja'}>
{item.name}
</LocalisedText>
</PrefetchLink>
{i === openItemIdx && (
@@ -561,7 +746,9 @@ const TopNav: FC<Props> = ({ user }) => {
? '_blank'
: undefined}
className="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12">
{subItem.name}
<LocalisedText language={subItem.nameLanguage ?? 'ja'}>
{subItem.name}
</LocalisedText>
</PrefetchLink>)))}
</div>)}
</Fragment>))}
@@ -570,7 +757,7 @@ const TopNav: FC<Props> = ({ user }) => {
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0)
&& 'top-nav-mobile-active font-bold'))}>
その他 &raquo;
<TranslatedText messageKey="more"/>
</PrefetchLink>
<TopNavUser user={user} sp/>
<Separator/>
@@ -605,7 +792,9 @@ const TopNav: FC<Props> = ({ user }) => {
setOpenItemIdx (i)
}
}}>
{item.name}
<LocalisedText language={item.nameLanguage ?? 'ja'}>
{item.name}
</LocalisedText>
</PrefetchLink>
<AnimatePresence initial={false}>
@@ -639,7 +828,10 @@ const TopNav: FC<Props> = ({ user }) => {
? '_blank'
: undefined}
className="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12">
{subItem.name}
<LocalisedText
language={subItem.nameLanguage ?? 'ja'}>
{subItem.name}
</LocalisedText>
</PrefetchLink>)))}
</motion.div>)}
</AnimatePresence>
@@ -649,7 +841,7 @@ const TopNav: FC<Props> = ({ user }) => {
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0)
&& 'top-nav-mobile-active font-bold'))}>
その他 &raquo;
<TranslatedText messageKey="more"/>
</PrefetchLink>
<TopNavUser user={user} sp/>
<Separator/>
+1 -1
ファイルの表示
@@ -17,7 +17,7 @@ describe ('TopNavUser', () => {
expect (screen.getByRole ('link', { name: '山田' })).toHaveAttribute (
'href',
'/users/settings',
'/ja/users/settings',
)
})
+4 -3
ファイルの表示
@@ -1,6 +1,7 @@
import Separator from '@/components/MenuSeparator'
import PrefetchLink from '@/components/PrefetchLink'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react'
@@ -11,6 +12,7 @@ type Props = { user: User | null,
const TopNavUser: FC<Props> = ({ user, sp }) => {
const { label } = useUITranslation ('shared')
if (!(user))
return
@@ -23,9 +25,8 @@ const TopNavUser: FC<Props> = ({ user, sp }) => {
return (
<>
{sp && <Separator/>}
<PrefetchLink to="/users/settings"
className={className}>
{user.name || '名もなきニジラー'}
<PrefetchLink to="/users/settings" className={className}>
{user.name || label ('labels.anonymous')}
</PrefetchLink>
</>)
}
+23
ファイルの表示
@@ -0,0 +1,23 @@
import LocalisedText from '@/components/LocalisedText'
import { useUITranslation } from '@/lib/useUITranslation'
type Props = {
messageKey: string
namespace?: string
values?: Record<string, string | number>
className?: string }
const TranslatedText = (
{ messageKey, namespace = 'navigation', values, className }: Props,
) => {
const { message } = useUITranslation (namespace)
const result = message (messageKey, values)
return (
<LocalisedText language={result.language} className={className}>
{result.text}
</LocalisedText>)
}
export default TranslatedText
+28 -11
ファイルの表示
@@ -3,16 +3,20 @@ import { useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGFM from 'remark-gfm'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import PrefetchLink from '@/components/PrefetchLink'
import { wikiKeys } from '@/lib/queryKeys'
import remarkWikiAutoLink from '@/lib/remark-wiki-autolink'
import { fetchWikiPages } from '@/lib/wiki'
import { fetchWikiAutolinkCandidates } from '@/lib/wiki'
import { ainuMDXComponents, LocalisedDocument } from '@/mdx-components'
import type { FC } from 'react'
import type { Components } from 'react-markdown'
type Props = { title: string
body?: string }
body?: string
language?: string }
const mdComponents = { a: (({ href, children }) => (
['/', '.'].some (e => href?.startsWith (e))
@@ -25,20 +29,33 @@ const mdComponents = { a: (({ href, children }) => (
</a>))) } as const satisfies Components
const WikiBody: FC<Props> = ({ title, body }) => {
const WikiBody: FC<Props> = ({ title, body, language = 'und' }) => {
const { message } = useUITranslation ('wiki')
const locale = useLocale ()
const missingPage = message ('missingPage', {
url: `/wiki/new?title=${ encodeURIComponent (title) }` })
const contentLanguage = body ? language : missingPage.language
const matchLanguage = contentLanguage === 'und'
? 'ja' : (contentLanguage === 'ain-Kana' ? 'ain' : contentLanguage)
const { data } = useQuery ({
enabled: Boolean (body),
queryKey: wikiKeys.index ({ }),
queryFn: () => fetchWikiPages ({ }) })
const pageNames = (data ?? []).map (page => page.title).sort ((a, b) => b.length - a.length)
queryKey: wikiKeys.autolinkCandidates (matchLanguage, locale),
queryFn: () => fetchWikiAutolinkCandidates (matchLanguage, locale) })
const remarkPlugins = useMemo (
() => [() => remarkWikiAutoLink (pageNames), remarkGFM], [pageNames])
() => [() => remarkWikiAutoLink (data ?? [], matchLanguage, locale), remarkGFM],
[data, matchLanguage, locale])
const components = {
...((contentLanguage === 'ain' || contentLanguage === 'ain-Kana')
? ainuMDXComponents : { }),
...mdComponents }
return (
<ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}>
{body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`}
</ReactMarkdown>)
<LocalisedDocument language={contentLanguage}>
<ReactMarkdown components={components} remarkPlugins={remarkPlugins}>
{body || missingPage.text}
</ReactMarkdown>
</LocalisedDocument>)
}
export default WikiBody
export default WikiBody
+2 -2
ファイルの表示
@@ -1,7 +1,7 @@
import type { FC } from 'react'
import type { FC, ReactNode } from 'react'
type Props = { id?: string
messages?: string[] }
messages?: ReactNode[] }
export const FieldError: FC<Props> = ({ id, messages }: Props) => {
+2 -2
ファイルの表示
@@ -1,7 +1,7 @@
import type { FC } from 'react'
import type { FC, ReactNode } from 'react'
type Props = { id?: string
messages?: string[] }
messages?: ReactNode[] }
export const FieldWarning: FC<Props> = ({ id, messages }: Props) => {
+1 -1
ファイルの表示
@@ -16,7 +16,7 @@ type Props = {
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void }
className?: string
label: ReactNode
messages?: string[] }
messages?: ReactNode[] }
const FormField: FC<Props> = ({ children, checkBox, className, label, messages }: Props) => {
+46
ファイルの表示
@@ -0,0 +1,46 @@
import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { currentRenderContext } from '@/lib/renderContext'
import type { ReactNode } from 'react'
type LocalisedTitle = { text: string; language: string }
type Props = {
title?: string | LocalisedTitle
includeSiteName?: boolean
description?: LocalisedTitle
canonical?: string
children?: ReactNode }
const PageHead = (
{ title, includeSiteName = true, description: pageDescription, canonical, children }: Props,
) => {
const locale = useLocale ()
const location = useLocation ()
const origin = currentRenderContext ()?.origin
const pageCanonical = canonical ?? (origin == null
? undefined : new URL (location.pathname + location.search, origin).href)
const { message, siteTitle } = useUITranslation ('shared')
const pageTitle = typeof title === 'string' ? { text: title, language: locale } : title
const fullTitle =
pageTitle == null
? siteTitle.text
: (includeSiteName ? `${pageTitle.text} | ${siteTitle.text}` : pageTitle.text)
const description = pageDescription ?? pageTitle
?? message ('head.description', { brand: siteTitle.text })
return (
<Helmet>
<title lang={pageTitle?.language ?? siteTitle.language}>{fullTitle}</title>
<meta name="description" content={description.text} lang={description.language}/>
{pageCanonical != null && <link rel="canonical" href={pageCanonical}/>}
{children}
</Helmet>)
}
export default PageHead
+3 -3
ファイルの表示
@@ -13,11 +13,11 @@ describe ('Pagination', () => {
expect (screen.getByLabelText ('前のページ')).toHaveAttribute (
'href',
'/posts?tags=abc&page=2',
'/ja/posts?tags=abc&page=2',
)
expect (screen.getByLabelText ('次のページ')).toHaveAttribute (
'href',
'/posts?tags=abc&page=4',
'/ja/posts?tags=abc&page=4',
)
expect (screen.getByText ('3')).toHaveAttribute ('aria-current', 'page')
})
@@ -33,6 +33,6 @@ describe ('Pagination', () => {
rerender (<Pagination page={1} totalPages={2}/>)
expect (screen.getByLabelText ('次のページ')).toHaveAttribute ('href', '/tags?page=2')
expect (screen.getByLabelText ('次のページ')).toHaveAttribute ('href', '/ja/tags?page=2')
})
})
+13 -5
ファイルの表示
@@ -1,6 +1,7 @@
import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react'
@@ -49,6 +50,7 @@ const getPages = (
const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
const location = useLocation ()
const { message } = useUITranslation ()
const buildTo = (p: number) => {
const qs = new URLSearchParams (location.search)
@@ -59,7 +61,9 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
const pages = getPages (page, totalPages, siblingCount)
return (
<nav className="mt-4 flex justify-center" aria-label="Pagination">
<nav className="mt-4 flex justify-center"
lang={message ('pagination.label').language}
aria-label={message ('pagination.label').text}>
<div className="flex items-center gap-2">
{(page > 1)
? (
@@ -67,13 +71,15 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
<PrefetchLink
className="md:hidden p-2"
to={buildTo (1)}
aria-label="最初のページ">
lang={message ('pagination.first').language}
aria-label={message ('pagination.first').text}>
|&lt;
</PrefetchLink>
<PrefetchLink
className="p-2"
to={buildTo (page - 1)}
aria-label="前のページ">
lang={message ('pagination.previous').language}
aria-label={message ('pagination.previous').text}>
&lt;
</PrefetchLink>
</>)
@@ -106,13 +112,15 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
<PrefetchLink
className="p-2"
to={buildTo (page + 1)}
aria-label="次のページ">
lang={message ('pagination.next').language}
aria-label={message ('pagination.next').text}>
&gt;
</PrefetchLink>
<PrefetchLink
className="md:hidden p-2"
to={buildTo (totalPages)}
aria-label="最後のページ">
lang={message ('pagination.last').language}
aria-label={message ('pagination.last').text}>
&gt;|
</PrefetchLink>
</>)
+1 -1
ファイルの表示
@@ -3,7 +3,7 @@ import type { FC } from 'react'
import React, { useState } from 'react'
import { cn } from '@/lib/utils'
type TabProps = { name: string
type TabProps = { name: React.ReactNode
init?: boolean
children: React.ReactNode }
+6 -3
ファイルの表示
@@ -1,8 +1,9 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagInput from '@/components/common/TagInput'
import { buildTag } from '@/test/factories'
import { renderWithProviders as render } from '@/test/render'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
@@ -26,7 +27,8 @@ describe ('TagInput', () => {
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ params: { q: '虹夏', nico: '0' } },
{ locale: 'ja', params: { q: '虹夏', nico: '0' },
signal: expect.any (AbortSignal) },
)
})
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
@@ -53,7 +55,8 @@ describe ('TagInput', () => {
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ params: { q: '虹夏', nico: '1' } },
{ locale: 'ja', params: { q: '虹夏', nico: '1' },
signal: expect.any (AbortSignal) },
)
})
})
+53 -18
ファイルの表示
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useLocale } from '@/lib/useLocale'
import TagSearchBox from '@/components/TagSearchBox'
import { apiGet } from '@/lib/api'
import { inputClass } from '@/lib/utils'
@@ -24,35 +25,69 @@ const TagInput: FC<Props> = ({
value,
setValue,
}) => {
const locale = useLocale ()
const [activeIndex, setActiveIndex] = useState (-1)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [request, setRequest] = useState ({ query: '' })
const [result, setResult] = useState<{
locale: string
includeNico: boolean
request: { query: string }
tags: Tag[] }> ({ locale, includeNico, request, tags: [] })
const suggestions = (result.locale === locale && result.includeNico === includeNico
&& result.request === request
&& request.query === value.trim ().split (' ').at (-1)) ? result.tags : []
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
// TODO: TagSearch からのコピペのため,共通化を考へる.
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
const whenChanged = (ev: ChangeEvent<HTMLInputElement>) => {
setValue (ev.target.value)
const q = ev.target.value.trim ().split (' ').at (-1)
if (!(q))
{
setSuggestions ([])
return
}
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: {
q,
nico: includeNico ? '1' : '0' } })
const nextSuggestions = data.filter (t => t.postCount > 0)
setSuggestions (nextSuggestions)
setSuggestionsVsbl (nextSuggestions.length > 0)
const query = ev.target.value.trim ().split (' ').at (-1) ?? ''
setRequest ({ query })
setActiveIndex (-1)
setSuggestionsVsbl (query !== '')
}
useEffect (() => {
if (request.query === '')
return
const controller = new AbortController ()
let active = true
void (async () => {
try
{
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
locale, signal: controller.signal,
params: { q: request.query, nico: includeNico ? '1' : '0' } })
if (active)
{
setResult ({ locale, includeNico, request,
tags: tags.filter (tag => tag.postCount > 0) })
}
}
catch
{
if (active)
setResult ({ locale, includeNico, request, tags: [] })
}
}) ()
return () => {
active = false
controller.abort ()
}
}, [request, locale, includeNico])
useEffect (() => {
setActiveIndex (-1)
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}, [locale, includeNico])
// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleTagSelect = (tag: Tag) => {
const parts = value?.split (' ')
parts[parts.length - 1] = tag.name
setValue (parts.join (' ') + ' ')
setSuggestions ([])
setRequest ({ query: '' })
setActiveIndex (-1)
}
+4 -3
ファイルの表示
@@ -1,6 +1,7 @@
import { useCallback, useEffect } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { MemoryRouter } from 'react-router-dom'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import useDialogue from '@/lib/dialogues/useDialogue'
@@ -49,7 +50,7 @@ describe ('DialogueProvider', () => {
</button>)
}
render (<DialogueProvider><Launcher/></DialogueProvider>)
render (<MemoryRouter><DialogueProvider><Launcher/></DialogueProvider></MemoryRouter>)
fireEvent.click (screen.getByRole ('button', { name: '開く' }))
expect (screen.getByText ('一件目')).toBeInTheDocument ()
@@ -73,7 +74,7 @@ describe ('DialogueProvider', () => {
</button>)
}
render (<DialogueProvider><Launcher/></DialogueProvider>)
render (<MemoryRouter><DialogueProvider><Launcher/></DialogueProvider></MemoryRouter>)
fireEvent.click (screen.getByRole ('button', { name: '開く' }))
const dialogue = screen.getByRole ('dialog')
@@ -101,7 +102,7 @@ describe ('DialogueProvider', () => {
</button>)
}
render (<DialogueProvider><Launcher/></DialogueProvider>)
render (<MemoryRouter><DialogueProvider><Launcher/></DialogueProvider></MemoryRouter>)
fireEvent.click (screen.getByRole ('button', { name: '開く' }))
const action = await screen.findByRole ('button', { name: '左操作' })
fireEvent.click (action)
+20 -14
ファイルの表示
@@ -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[]>> ({ })
@@ -88,8 +90,9 @@ const DialogueProvider: FC<Props> = ({ children }) => {
})
setPendingIds (current => current.filter (pendingId => pendingId !== id))
setFormActions (current => {
const { [id]: _, ...rest } = current
return rest
const rest = { ...current }
delete rest[id]
return rest
})
delete formControls.current[id]
}, [])
@@ -178,6 +181,8 @@ const DialogueProvider: FC<Props> = ({ children }) => {
closeRequest (active.id, active.kind !== 'confirm' && null)
}}>
<DialogContent
closeLabel={(active.kind === 'confirm' || active.kind === 'choice')
? active.options.closeLabel : undefined}
className={
active.kind === 'form'
? `flex max-h-[calc(100dvh-1rem)] ${ active.options.size === 'large'
@@ -217,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'
@@ -239,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'
@@ -275,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
@@ -283,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' && (
@@ -297,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 => (
@@ -322,7 +327,8 @@ const DialogueProvider: FC<Props> = ({ children }) => {
if (!(open))
closeNestedConfirm (false)
}}>
<DialogContent className="px-6 pb-6 pt-7">
<DialogContent className="px-6 pb-6 pt-7"
closeLabel={nestedConfirm.options.closeLabel}>
<DialogHeader className="pl-8">
<DialogTitle>{nestedConfirm.options.title}</DialogTitle>
@@ -336,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
@@ -344,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>
+2 -1
ファイルの表示
@@ -1,8 +1,9 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { renderWithProviders as render } from '@/test/render'
const sidebarWidth = (element: Element | null): string =>
+5 -7
ファイルの表示
@@ -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 (
@@ -101,12 +103,7 @@ const SidebarComponent: FC<Props> = ({
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth))
}, [])
const [width, setWidth] = useState (() => (
typeof window === 'undefined'
? DEFAULT_SIDEBAR_WIDTH
: getClampedWidth (
readStoredSidebarWidth (sidebarKey, side),
window.innerWidth)))
const [width, setWidth] = useState (DEFAULT_SIDEBAR_WIDTH)
const [resizing, setResizing] = useState (false)
const widthRef = useRef (width)
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
@@ -341,7 +338,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 -4
ファイルの表示
@@ -1,10 +1,11 @@
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField'
import TranslatedText from '@/components/TranslatedText'
import type { FC, ReactNode } from 'react'
type TextMessages = string[] | undefined
type TextMessages = ReactNode[] | undefined
type CoreField = {
value: string
@@ -42,7 +43,7 @@ type PostCoreDataFieldsProps = {
rows?: number }
parentPostIds: CoreField }
const groupedMessages = (...values: (TextMessages | null | undefined)[]): string[] =>
const groupedMessages = (...values: (TextMessages | null | undefined)[]): ReactNode[] =>
[...new Set (values.flatMap (value => value ?? []))]
@@ -54,7 +55,7 @@ const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
) => (
<>
<PostTextField
label="タイトル"
label={<TranslatedText namespace="shared" messageKey="labels.title"/>}
value={title.value}
disabled={title.disabled}
warnings={title.warnings}
@@ -82,7 +83,7 @@ const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
rows={tags.rows}/>
<PostTextField
label="親投稿"
label={<TranslatedText namespace="shared" messageKey="labels.parentPosts"/>}
value={parentPostIds.value}
disabled={parentPostIds.disabled}
warnings={parentPostIds.warnings}
+3 -2
ファイルの表示
@@ -1,11 +1,12 @@
import PostCoreDataFields from '@/components/posts/PostCoreDataFields'
import PostTextField from '@/components/posts/PostTextField'
import TranslatedText from '@/components/TranslatedText'
import type { FC, ReactNode } from 'react'
import type { PostCoreDataFieldsProps } from '@/components/posts/PostCoreDataFields'
type TextMessages = string[] | undefined
type TextMessages = ReactNode[] | undefined
type Props = {
url: {
@@ -29,7 +30,7 @@ const PostCreationDataFields: FC<Props> = (
) => (
<>
<PostTextField
label="URL"
label={<TranslatedText namespace="shared" messageKey="url"/>}
type={url.type}
value={url.value}
disabled={url.disabled}
+2 -1
ファイルの表示
@@ -1,7 +1,8 @@
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders as render } from '@/test/render'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
+5 -5
ファイルの表示
@@ -1,11 +1,12 @@
import PostTextField from '@/components/posts/PostTextField'
import TranslatedText from '@/components/TranslatedText'
import type { FC } from 'react'
import type { FC, ReactNode } from 'react'
type Props = {
value: string
onChange: (value: string) => void
errors?: string[]
errors?: ReactNode[]
disabled?: boolean }
@@ -16,12 +17,11 @@ const PostDurationField: FC<Props> = (
disabled },
) => (
<PostTextField
label="動画時間"
label={<TranslatedText namespace="shared" messageKey="labels.duration"/>}
value={value}
onChange={onChange}
errors={errors}
disabled={disabled}
type="text"/>
)
type="text"/>)
export default PostDurationField
+3 -3
ファイルの表示
@@ -1,13 +1,13 @@
import PostFormTagsArea from '@/components/PostFormTagsArea'
import FieldWarning from '@/components/common/FieldWarning'
import type { ComponentPropsWithoutRef, FC } from 'react'
import type { ComponentPropsWithoutRef, FC, ReactNode } from 'react'
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange'> & {
tags: string
setTags: (tags: string) => void
warnings?: string[]
errors?: string[] }
warnings?: ReactNode[]
errors?: ReactNode[] }
const PostTagsField: FC<Props> = (
+3 -3
ファイルの表示
@@ -5,11 +5,11 @@ import { inputClass } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
type Props = {
label: string
label: ReactNode
value: string
onChange: (value: string) => void
warnings?: string[]
errors?: string[]
warnings?: ReactNode[]
errors?: ReactNode[]
disabled?: boolean
type?: string
placeholder?: string
+2 -1
ファイルの表示
@@ -1,7 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import { renderWithProviders as render } from '@/test/render'
describe ('PostThumbnailPreview', () => {
it ('keeps an existing blob preview URL unchanged for normal post forms', () => {
+4 -2
ファイルの表示
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react'
@@ -15,10 +16,11 @@ type Props = {
const PostThumbnailPreview: FC<Props> = (
{ url,
file,
alt = 'サムネール',
alt,
className = 'h-16 w-16',
referrerPolicy },
) => {
const { message } = useUITranslation ('shared')
const [failed, setFailed] = useState (false)
const [fileUrl, setFileUrl] = useState<string | null> (null)
@@ -55,7 +57,7 @@ const PostThumbnailPreview: FC<Props> = (
return (
<img
src={resolvedUrl}
alt={alt}
alt={alt ?? message ('labels.thumbnail').text}
referrerPolicy={referrerPolicy}
className={cn (className, 'rounded border border-border object-cover')}
onError={() => setFailed (true)}/>)
+12 -8
ファイルの表示
@@ -1,8 +1,10 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import { act, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders as render } from '@/test/render'
import { textContentOf } from '@/test/text'
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
@@ -37,7 +39,7 @@ describe ('PostImportRowForm', () => {
const titleInput = screen.getByDisplayValue ('manual title')
await waitFor (() => expect (actions.at (-1)?.length).toBe (2))
const reset = actions.at (-1)?.find (action => action.label === '変更をリセット')
const reset = actions.at (-1)?.find (action => textContentOf (action.label) === '変更をリセット')
expect (reset).toMatchObject ({ placement: 'start', variant: 'danger', disabled: false })
await act (async () => {
await reset?.onSelect ()
@@ -47,7 +49,7 @@ describe ('PostImportRowForm', () => {
expect (titleInput).toHaveValue ('')
expect (onSave).not.toHaveBeenCalled ()
const save = actions.at (-1)?.find (action => action.label === '編輯内容を保存')
const save = actions.at (-1)?.find (action => textContentOf (action.label) === '編輯内容を保存')
await act (async () => {
await save?.onSelect ()
})
@@ -76,7 +78,7 @@ describe ('PostImportRowForm', () => {
await waitFor (() => expect (actions.length).toBe (2))
await act (async () => {
await actions.find (action => action.label === '変更をリセット')?.onSelect ()
await actions.find (action => textContentOf (action.label) === '変更をリセット')?.onSelect ()
})
expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument ()
@@ -124,7 +126,7 @@ describe ('PostImportRowForm', () => {
await waitFor (() => expect (actions.length).toBe (2))
await act (async () => {
void actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
void actions.find (action => textContentOf (action.label) === '編輯内容を保存')?.onSelect ()
})
expect (onSave).toHaveBeenCalledWith ({
@@ -171,7 +173,7 @@ describe ('PostImportRowForm', () => {
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
await act (async () => {
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
await actions.find (action => textContentOf (action.label) === '編輯内容を保存')?.onSelect ()
})
expect (onSave).toHaveBeenCalledWith ({
@@ -218,7 +220,8 @@ describe ('PostImportRowForm', () => {
onSave={vi.fn ()}/>)
await waitFor (() => expect (actions.length).toBe (2))
expect (actions.find (action => action.label === '変更をリセット')?.disabled).toBe (false)
const reset = actions.find (action => textContentOf (action.label) === '変更をリセット')
expect (reset?.disabled).toBe (false)
})
it (
@@ -249,7 +252,8 @@ describe ('PostImportRowForm', () => {
let savePromise: Promise<boolean | void> | undefined
await act (async () => {
savePromise = actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
const save = actions.find (action => textContentOf (action.label) === '編輯内容を保存')
savePromise = save?.onSelect ()
})
await waitFor (() => {
+24 -19
ファイルの表示
@@ -7,8 +7,11 @@ import PostDurationField from '@/components/posts/PostDurationField'
import PostTextField from '@/components/posts/PostTextField'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import { hasThumbnailBaseValue, hasVideoTag } from '@/lib/postImportRows'
import { renderPostImportMessage } from '@/components/posts/import/postImportRowStatus'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react'
import type { FC, ReactNode } from 'react'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportTypes'
@@ -47,9 +50,6 @@ const buildResetDraft = (row: PostImportRow): Draft => ({
duration: String (row.resetSnapshot.attributes.duration ?? ''),
thumbnailFile: undefined })
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
[...new Set (values.flatMap (value => value ?? []))]
const sameDraft = (left: Draft, right: Draft): boolean =>
left.url === right.url
&& left.title === right.title
@@ -98,6 +98,11 @@ const PostImportRowForm: FC<Props> = (
controls,
onSave },
) => {
const locale = useLocale ()
const { label } = useUITranslation ('imports')
const groupedMessages = (...values: (string[] | undefined)[]): ReactNode[] =>
[...new Set (values.flatMap (value => value ?? []))]
.map (value => renderPostImportMessage (value, locale))
const [draft, setDraft] = useState<Draft> (() => buildDraft (row))
const [messageRow, setMessageRow] = useState<PostImportRow | null> (null)
const [saving, setSaving] = useState (false)
@@ -144,9 +149,9 @@ const PostImportRowForm: FC<Props> = (
return false
const confirmed = await controls.confirm ({
title: '変更をリセットしますか?',
confirmText: 'リセット',
cancelText: '取消',
title: label ('shared:messages.resetChanges'),
confirmText: label ('shared:actions.reset'),
cancelText: label ('shared:actions.cancel'),
variant: 'danger' })
if (!(confirmed))
return false
@@ -156,7 +161,7 @@ const PostImportRowForm: FC<Props> = (
setMessageRow (null)
setCommittedThumbnailBase (resetDraft.thumbnailBase)
return false
}, [controls, resetDisabled, resetDraft])
}, [controls, resetDisabled, resetDraft, label])
const save = useCallback (async (): Promise<boolean> => {
setSaving (true)
@@ -178,16 +183,16 @@ const PostImportRowForm: FC<Props> = (
useEffect (() => {
controls.setActions ([{
label: '変更をリセット',
label: label ('review.reset'),
placement: 'start',
variant: 'danger',
disabled: resetDisabled,
onSelect: reset },
{
label: '編輯内容を保存',
label: label ('review.save'),
disabled: saving,
onSelect: save }])
}, [controls, resetDisabled, reset, save, saving])
}, [controls, resetDisabled, reset, save, saving, label])
return (
<>
@@ -210,17 +215,17 @@ const PostImportRowForm: FC<Props> = (
value: draft.url,
onChange: value => update ('url', value),
disabled: saving,
warnings: displayRow.fieldWarnings.url,
warnings: groupedMessages (displayRow.fieldWarnings.url),
errors: groupedMessages (
displayRow.validationErrors.url,
displayRow.importErrors?.url) }}
thumbnailField={
<>
<PostTextField
label="サムネール"
label={label ('shared:labels.thumbnail')}
value={draft.thumbnailBase}
disabled={saving}
warnings={currentThumbnailWarnings}
warnings={groupedMessages (currentThumbnailWarnings)}
errors={groupedMessages (
displayRow.validationErrors.thumbnailBase,
displayRow.importErrors?.thumbnailBase)}
@@ -244,7 +249,7 @@ const PostImportRowForm: FC<Props> = (
value: draft.title,
onChange: value => update ('title', value),
disabled: saving,
warnings: displayRow.fieldWarnings.title,
warnings: groupedMessages (displayRow.fieldWarnings.title),
errors: groupedMessages (
displayRow.validationErrors.title,
displayRow.importErrors?.title) },
@@ -270,7 +275,7 @@ const PostImportRowForm: FC<Props> = (
value: draft.tags,
onChange: value => update ('tags', value),
disabled: saving,
warnings: displayRow.fieldWarnings.tags,
warnings: groupedMessages (displayRow.fieldWarnings.tags),
errors: groupedMessages (
displayRow.validationErrors.tags,
displayRow.importErrors?.tags),
@@ -293,9 +298,9 @@ const PostImportRowForm: FC<Props> = (
displayRow.validationErrors.videoMs,
displayRow.importErrors?.videoMs)}/>)
: null}/>
<FieldWarning messages={displayRow.baseWarnings}/>
<FieldError messages={displayRow.validationErrors.base}/>
<FieldError messages={displayRow.importErrors?.base}/>
<FieldWarning messages={groupedMessages (displayRow.baseWarnings)}/>
<FieldError messages={groupedMessages (displayRow.validationErrors.base)}/>
<FieldError messages={groupedMessages (displayRow.importErrors?.base)}/>
</div>
</div>
</div>
+25 -16
ファイルの表示
@@ -3,7 +3,11 @@ import PostImportTagLinks from '@/components/posts/import/PostImportTagLinks'
import { Button } from '@/components/ui/button'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { displayPostImportStatus,
renderPostImportMessage } from '@/components/posts/import/postImportRowStatus'
import LocalisedText from '@/components/LocalisedText'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import {
canEditReviewRow,
canRetryResultRow,
@@ -14,6 +18,7 @@ import { cn, originalCreatedAtString } from '@/lib/utils'
import type { FC } from 'react'
import type { PostImportRow } from '@/lib/postImportTypes'
import type { LocaleCode } from '@/lib/locales'
type Props = {
row: PostImportRow
@@ -33,10 +38,10 @@ const summaryWarning = (row: PostImportRow): string | null =>
?? row.baseWarnings?.[0]
?? null
const summaryDate = (row: PostImportRow): string =>
const summaryDate = (row: PostImportRow, locale: LocaleCode): string =>
originalCreatedAtString (
row.attributes.originalCreatedFrom?.toString () ?? null,
row.attributes.originalCreatedBefore?.toString () ?? null)
row.attributes.originalCreatedBefore?.toString () ?? null, locale)
const PostImportRowSummary: FC<Props> = (
@@ -52,6 +57,10 @@ const PostImportRowSummary: FC<Props> = (
showActions = true,
showSkipToggle = false },
) => {
const locale = useLocale ()
const { label } = useUITranslation ('imports')
const localisedRowMessages = rowMessages?.map (value =>
renderPostImportMessage (value, locale))
const warning = summaryWarning (row)
const displayStatus = displayPostImportStatus (row)
const editVisible = onEdit != null
@@ -69,7 +78,7 @@ const PostImportRowSummary: FC<Props> = (
checked={skipChecked}
onChange={event => onToggleSkip?.(event.target.checked)}
disabled={skipDisabled === true}/>
<span>スキップ</span>
<span>{label ('shared:actions.skip')}</span>
</label>)
: null
@@ -96,17 +105,17 @@ const PostImportRowSummary: FC<Props> = (
</div>
<PostImportTagLinks tags={row.displayTags}/>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)}
<LocalisedText language={locale}>{summaryDate (row, locale)}</LocalisedText>
</div>
{showDuration && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
動画時間 {duration}
{label ('review.duration', { duration })}
</div>)}
{warning && (
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
{renderPostImportMessage (warning, locale)}
</div>)}
<FieldError messages={rowMessages}/>
<FieldError messages={localisedRowMessages}/>
</div>
<div className="space-y-1">
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
@@ -120,7 +129,7 @@ const PostImportRowSummary: FC<Props> = (
variant="outline"
onClick={onEdit}
disabled={editDisabled === true || !(editAllowed)}>
編輯
{label ('shared:actions.edit')}
</Button>)}
{showActions && retryAllowed && (
<Button
@@ -128,7 +137,7 @@ const PostImportRowSummary: FC<Props> = (
variant="outline"
onClick={onRetry}
disabled={retryDisabled === true}>
再試行
{label ('shared:actions.retry')}
</Button>)}
</div>
</div>
@@ -156,17 +165,17 @@ const PostImportRowSummary: FC<Props> = (
</div>
<PostImportTagLinks tags={row.displayTags}/>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)}
<LocalisedText language={locale}>{summaryDate (row, locale)}</LocalisedText>
</div>
{showDuration && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
動画時間 {duration}
{label ('review.duration', { duration })}
</div>)}
{warning && (
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
{renderPostImportMessage (warning, locale)}
</div>)}
<FieldError messages={rowMessages}/>
<FieldError messages={localisedRowMessages}/>
{skipControl}
</div>
</div>
@@ -179,7 +188,7 @@ const PostImportRowSummary: FC<Props> = (
variant="outline"
onClick={onEdit}
disabled={editDisabled === true || !(editAllowed)}>
編輯
{label ('shared:actions.edit')}
</Button>)}
{retryAllowed && (
<Button
@@ -188,7 +197,7 @@ const PostImportRowSummary: FC<Props> = (
variant="outline"
onClick={onRetry}
disabled={retryDisabled === true}>
再試行
{label ('shared:actions.retry')}
</Button>)}
</div>)}
</div>
+2 -9
ファイルの表示
@@ -1,4 +1,5 @@
import StatusBadge from '@/components/common/StatusBadge'
import TranslatedText from '@/components/TranslatedText'
import type { FC } from 'react'
@@ -8,14 +9,6 @@ import type { PostImportBadgeValue } from '@/components/posts/import/postImportR
type Props = {
value: PostImportBadgeValue }
const LABELS: Record<PostImportBadgeValue, string> = {
ready: '登録可能',
error: '登録不可',
warning: '警告',
skipped: 'スキップ',
created: '登録済み',
failed: '登録失敗' }
const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
ready: 'success',
error: 'warning',
@@ -27,7 +20,7 @@ const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
const PostImportStatusBadge: FC<Props> = ({ value }) => (
<StatusBadge tone={TONES[value]}>
{LABELS[value]}
<TranslatedText namespace="imports" messageKey={`status.${ value }`}/>
</StatusBadge>)
export default PostImportStatusBadge
+2 -1
ファイルの表示
@@ -1,7 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import { renderWithProviders as render } from '@/test/render'
describe ('PostImportThumbnailPreview', () => {
beforeEach (() => {
+1 -1
ファイルの表示
@@ -12,7 +12,7 @@ type Props = {
const PostImportThumbnailPreview: FC<Props> = (
{ url,
file,
alt = 'サムネール',
alt,
className = 'h-16 w-16' },
) => (
<PostThumbnailPreview
+41
ファイルの表示
@@ -1,3 +1,10 @@
import { createElement } from 'react'
import LocalisedText from '@/components/LocalisedText'
import { i18n, translateUIMessage } from '@/lib/i18n'
import type { ReactNode } from 'react'
import type { PostImportRow } from '@/lib/postImportTypes'
export type PostImportDisplayStatus =
@@ -10,6 +17,40 @@ export type PostImportDisplayStatus =
export type PostImportBadgeValue = PostImportDisplayStatus
const IMPORT_MESSAGE_KEYS = [
'validation.duplicateUrl', 'validation.processingFailed',
'validation.tooMany', 'validation.urlTooLong', 'validation.invalidUrl',
'validation.protocol', 'validation.duplicateRow', 'storage.readFailed',
'storage.writeFailed', 'storage.clearFailed', 'source.urlRequired',
'shared:labels.thumbnailMissing']
// Keep stored and compared messages intact; translate only recognised UI messages at display time.
export const renderPostImportMessage = (value: string, locale: string): ReactNode => {
for (const reference of IMPORT_MESSAGE_KEYS)
{
const [namespace, key] =
reference.includes (':')
? reference.split (':')
: ['imports', reference]
const template: unknown = i18n.getResource ('ja', namespace, key)
if (typeof template !== 'string')
continue
const names = [...template.matchAll (/\{\{(\w+)\}\}/g)].map (match => match[1])
const pattern = template.split (/(\{\{\w+\}\})/).map (part =>
part.startsWith ('{{')
? '(\\d+)'
: part.replace (/[.*+?^${}()|[\]\\]/g, '\\$&')).join ('')
const match = value.match (new RegExp (`^${pattern}$`))
if (match == null)
continue
const values = Object.fromEntries (names.map ((name, index) => [name, match[index + 1]]))
const result = translateUIMessage (key, namespace, locale, values)
return createElement (LocalisedText, { language: result.language, children: result.text })
}
return value
}
const hasWarnings = (row: PostImportRow): boolean =>
Object.values (row.fieldWarnings ?? { }).some (messages => messages.length > 0)
|| row.baseWarnings.length > 0
+40 -29
ファイルの表示
@@ -3,7 +3,10 @@
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import LocalisedText from '@/components/LocalisedText'
import { translateUIMessage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
@@ -32,37 +35,45 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
& { closeLabel?: React.ReactNode }
>(
({ className, children, ...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">閉ぢる</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 = (
+1 -1
ファイルの表示
@@ -11,7 +11,7 @@ import type {
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
type ToasterToast = Omit<ToastProps, 'title'> & {
id: string
title?: React.ReactNode
description?: React.ReactNode
+31 -36
ファイルの表示
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { getClientBehaviorSettings, setClientBehaviorSettings } from '@/lib/settings'
import { useUITranslation } from '@/lib/useUITranslation'
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
@@ -22,7 +23,7 @@ type Props = {
type SegmentedOption<T extends string> = {
value: T
label: string }
label: ReactNode }
type SegmentedControlProps<T extends string> = {
value: T
@@ -30,27 +31,18 @@ type SegmentedControlProps<T extends string> = {
onChange: (value: T) => void }
type SettingBlockProps = {
title: string
description?: string
title: ReactNode
description?: ReactNode
children: ReactNode }
const animationOptions: SegmentedOption<ClientAnimationMode>[] = [
{ value: 'off', label: 'なし' },
{ value: 'reduced', label: '控えめ' },
{ value: 'normal', label: '通常' }]
const animationOptions: ClientAnimationMode[] = ['off', 'reduced', 'normal']
const embedAutoLoadOptions: SegmentedOption<ClientEmbedAutoLoadMode>[] = [
{ value: 'off', label: '読み込まない' },
{ value: 'manual', label: 'クリック時' },
{ value: 'auto', label: '自動' }]
const embedAutoLoadOptions: ClientEmbedAutoLoadMode[] = ['off', 'manual', 'auto']
const linkPreloadOptions: SegmentedOption<ClientLinkPreloadMode>[] = [
{ value: 'off', label: 'しない' },
{ value: 'intent', label: 'する' }]
const linkPreloadOptions: ClientLinkPreloadMode[] = ['off', 'intent']
const tagRelationDisplayOptions: ClientTagRelationDisplayMode[] = ['flat', 'grouped']
const tagRelationDisplayOptions: SegmentedOption<ClientTagRelationDisplayMode>[] = [
{ value: 'flat', label: 'まとめない' },
{ value: 'grouped', label: 'まとめる' }]
const SegmentedControl = <T extends string,> (
{ value, options, onChange }: SegmentedControlProps<T>,
@@ -92,14 +84,13 @@ const SettingBlock: FC<SettingBlockProps> = (
</div>)
const BehaviourSettingsSection: FC<Props> = (
{ sectionClassName,
onDirtyStateChange },
) => {
const BehaviourSettingsSection: FC<Props> = props => {
const { sectionClassName, onDirtyStateChange } = props
const { label } = useUITranslation ('settings')
const [savedSettings, setSavedSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
useState<ClientBehaviorSettings> ({ })
const [draftSettings, setDraftSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
useState<ClientBehaviorSettings> ({ })
useEffect (() => {
const current = getClientBehaviorSettings ()
@@ -122,7 +113,7 @@ const BehaviourSettingsSection: FC<Props> = (
const next = setClientBehaviorSettings (draftSettings)
setSavedSettings (next)
setDraftSettings (next)
toast ({ title: '動作設定を保存しました' })
toast ({ title: label ('behavior.saved') })
}
const handleDiscard = () => {
@@ -141,7 +132,7 @@ const BehaviourSettingsSection: FC<Props> = (
<section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="text-xl font-bold">動作</h2>
<h2 className="text-xl font-bold">{label ('tabs.behavior')}</h2>
</div>
<div className="flex flex-wrap gap-2">
@@ -150,45 +141,49 @@ const BehaviourSettingsSection: FC<Props> = (
variant="destructive"
disabled={hasUnsavedChanges === false}
onClick={handleDiscard}>
変更を破棄
{label ('actions.discard')}
</Button>
<Button
type="button"
disabled={hasUnsavedChanges === false}
onClick={handleSave}>
保存
{label ('actions.save')}
</Button>
</div>
</div>
<div className="space-y-4">
<SettingBlock title="アニメーション">
<SettingBlock title={label ('behavior.animation')}>
<SegmentedControl
value={draftSettings.animation ?? 'normal'}
options={animationOptions}
options={animationOptions.map (value => ({ value,
label: label (`behavior.animationOptions.${value}`) }))}
onChange={value => updateDraft ('animation', value)}/>
</SettingBlock>
<SettingBlock
title="リンク先の先読み"
description="マウスを置いた時点でリンク先のデータを取得します。">
title={label ('behavior.preload')}
description={label ('behavior.preloadDescription')}>
<SegmentedControl
value={draftSettings.linkPreload ?? 'intent'}
options={linkPreloadOptions}
options={linkPreloadOptions.map (value => ({ value,
label: label (`behavior.preloadOptions.${value}`) }))}
onChange={value => updateDraft ('linkPreload', value)}/>
</SettingBlock>
<SettingBlock title="タグの親子関係表示">
<SettingBlock title={label ('behavior.tagRelations')}>
<SegmentedControl
value={draftSettings.tagRelationDisplay ?? 'grouped'}
options={tagRelationDisplayOptions}
options={tagRelationDisplayOptions.map (value => ({ value,
label: label (`behavior.relationOptions.${value}`) }))}
onChange={value => updateDraft ('tagRelationDisplay', value)}/>
</SettingBlock>
<SettingBlock title="埋め込み自動読込">
<SettingBlock title={label ('behavior.embeds')}>
<SegmentedControl
value={draftSettings.embedAutoLoad ?? 'auto'}
options={embedAutoLoadOptions}
options={embedAutoLoadOptions.map (value => ({ value,
label: label (`behavior.embedOptions.${value}`) }))}
onChange={value => updateDraft ('embedAutoLoad', value)}/>
</SettingBlock>
</div>
+21 -14
ファイルの表示
@@ -1,8 +1,6 @@
import type { FC } from 'react'
import { useState } from 'react'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { useDialogue } from '@/lib/dialogues/useDialogue'
import { Button } from '@/components/ui/button'
import { Dialog,
DialogContent,
@@ -12,6 +10,10 @@ import { Dialog,
import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/use-toast'
import { apiPost } from '@/lib/api'
import { useUITranslation } from '@/lib/useUITranslation'
import { rememberUserCode } from '@/lib/users'
import type { FC } from 'react'
import type { User } from '@/types'
@@ -22,14 +24,17 @@ type Props = { visible: boolean
const InheritDialogue: FC<Props> = ({ visible, onVisibleChange, setUser }) => {
const dialogue = useDialogue ()
const { message, label } = useUITranslation ('settings')
const [inputCode, setInputCode] = useState ('')
const handleTransfer = async () => {
if (!(await dialogue.confirm ({
title: '引継ぎを行ってもよろしいですか?',
description: '現在のアカウントからはログアウトされます.',
confirmText: '引継ぐ',
title: label ('transfer.confirm'),
description: label ('transfer.logoutWarning'),
confirmText: label ('transfer.submit'),
cancelText: label ('actions.cancel'),
closeLabel: label ('actions.close'),
variant: 'danger' })))
return
@@ -39,31 +44,33 @@ const InheritDialogue: FC<Props> = ({ visible, onVisibleChange, setUser }) => {
'/users/verify', { code: inputCode })
if (data.valid)
{
localStorage.setItem ('user_code', inputCode)
rememberUserCode (data.user.inheritanceCode)
setUser (data.user)
toast ({ title: '引継ぎ成功!' })
toast ({ title: label ('transfer.success') })
onVisibleChange (false)
}
else
toast ({ title: '認証失敗', description: 'そのコードは使へません.' })
toast ({ title: label ('transfer.authFailed'), description: label ('transfer.invalidCode') })
}
catch
{
toast ({ title: '通信エラー', description: 'またあとで試してね.' })
toast ({ title: label ('transfer.networkError'), description: label ('transfer.retryLater') })
}
}
return (
<Dialog open={visible} onOpenChange={onVisibleChange}>
<DialogContent className="px-6 pp-6 pt-7">
<DialogContent className="px-6 pp-6 pt-7" closeLabel={label ('actions.close')}>
<DialogHeader className="pl-8">
<DialogTitle>ほかのブラウザから引継ぐ</DialogTitle>
<DialogTitle>{label ('account.inherit')}</DialogTitle>
<DialogDescription asChild>
<div className="flex gap-2">
<Input placeholder="引継ぎコードを入力"
<Input placeholder={message ('transfer.placeholder').text}
aria-label={message ('transfer.placeholder').text}
lang={message ('transfer.placeholder').language}
value={inputCode}
onChange={ev => setInputCode (ev.target.value)}/>
<Button onClick={handleTransfer}>引継ぐ</Button>
<Button onClick={handleTransfer}>{label ('transfer.submit')}</Button>
</div>
</DialogDescription>
</DialogHeader>
+31 -26
ファイルの表示
@@ -8,14 +8,15 @@ import {
isSpaceBinding,
keyBindingFromKeyboardEvent,
SHORTCUT_DEFINITIONS,
SHORTCUT_SCOPE_LABELS,
} from '@/lib/keyboardShortcuts'
import { getEffectiveKeyBindings } from '@/lib/settings'
import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react'
import type { ShortcutActionId } from '@/lib/keyboardShortcuts'
import type { ClientKeyboardSettings } from '@/lib/settings'
import type { FC } from 'react'
type Props = {
sectionClassName: string
@@ -26,10 +27,9 @@ type Props = {
}
const KeyboardSettingsSection: FC<Props> = (
{ sectionClassName,
onDirtyStateChange },
) => {
const KeyboardSettingsSection: FC<Props> = props => {
const { sectionClassName, onDirtyStateChange } = props
const { label } = useUITranslation ('settings')
const {
keyboardSettings,
saveKeyboardSettings,
@@ -92,7 +92,7 @@ const KeyboardSettingsSection: FC<Props> = (
if (isSpaceBinding (binding))
{
toast ({ title: 'Space は今は割り当てられません.' })
toast ({ title: label ('shortcuts.spaceUnavailable') })
return
}
@@ -108,7 +108,7 @@ const KeyboardSettingsSection: FC<Props> = (
document.addEventListener ('keydown', onKeyDown, true)
return () => document.removeEventListener ('keydown', onKeyDown, true)
}, [capturingActionId])
}, [capturingActionId, label])
const rows = useMemo (() => SHORTCUT_DEFINITIONS.map (definition => ({
...definition,
@@ -126,7 +126,7 @@ const KeyboardSettingsSection: FC<Props> = (
const handleSave = () => {
saveKeyboardSettings (draftKeyboardSettings)
toast ({ title: 'キーボード設定を保存しました' })
toast ({ title: label ('shortcuts.saved') })
}
const handleDiscard = () => {
@@ -146,7 +146,7 @@ const KeyboardSettingsSection: FC<Props> = (
<section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="text-xl font-bold">キーボード</h2>
<h2 className="text-xl font-bold">{label ('tabs.keyboard')}</h2>
</div>
<div className="flex flex-wrap gap-2">
@@ -159,20 +159,20 @@ const KeyboardSettingsSection: FC<Props> = (
bindings: { },
}))
}}>
すべて既定に戻す
{label ('actions.resetAll')}
</Button>
<Button
type="button"
variant="destructive"
disabled={!(hasUnsavedChanges)}
onClick={handleDiscard}>
変更を破棄
{label ('actions.discard')}
</Button>
<Button
type="button"
disabled={!(hasUnsavedChanges) || draftConflictActionIds.size > 0}
onClick={handleSave}>
保存
{label ('actions.save')}
</Button>
</div>
</div>
@@ -187,33 +187,35 @@ const KeyboardSettingsSection: FC<Props> = (
enabled: event.target.checked,
}))
}}/>
<span>キーボード・ショートカットを有効にする</span>
<span>{label ('shortcuts.enable')}</span>
</label>
{capturingActionId != null && (
<p className="text-sm text-muted-foreground">
キー入力待ちです.Escape で取消し,Backspace / Delete で未割当にできます.
{label ('shortcuts.captureHelp')}
</p>)}
<div className="overflow-x-auto">
<table className="w-full min-w-[52rem] table-fixed border-collapse text-sm">
<thead className="border-b border-border">
<tr>
<th className="p-2 text-left">操作</th>
<th className="p-2 text-left">適用範囲</th>
<th className="p-2 text-left">現在のキー</th>
<th className="p-2 text-left">{label ('shortcuts.operation')}</th>
<th className="p-2 text-left">{label ('shortcuts.scope')}</th>
<th className="p-2 text-left">{label ('shortcuts.currentKey')}</th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left">競合状態</th>
<th className="p-2 text-left">{label ('shortcuts.conflictState')}</th>
</tr>
</thead>
<tbody>
{rows.map (row => (
<tr key={row.id} className="border-b border-border/60 last:border-b-0">
<td className="p-2">{row.label}</td>
<td className="p-2">{SHORTCUT_SCOPE_LABELS[row.scope]}</td>
<td className="p-2">{label (`shortcuts.actions.${row.id}`)}</td>
<td className="p-2">{label (`shortcuts.scopes.${row.scope}`)}</td>
<td className="p-2 font-mono">
{formatKeyBinding (row.currentBinding)}
{row.currentBinding == null
? label ('shortcuts.unassigned')
: <span lang="en">{formatKeyBinding (row.currentBinding)}</span>}
</td>
<td className="p-2">
<Button
@@ -225,7 +227,8 @@ const KeyboardSettingsSection: FC<Props> = (
capturingActionId === row.id ? null : row.id,
)
}}>
{capturingActionId === row.id ? '入力待ち…' : '変更'}
{label (capturingActionId === row.id
? 'shortcuts.capturing' : 'actions.change')}
</Button>
</td>
<td className="p-2">
@@ -244,13 +247,15 @@ const KeyboardSettingsSection: FC<Props> = (
}
})
}}>
既定に戻す
{label ('actions.reset')}
</Button>
</td>
<td className="p-2">
{row.hasConflict
? <span className="text-red-600 dark:text-red-400">競合あり</span>
: 'なし'}
? <span className="text-red-600 dark:text-red-400">
{label ('shortcuts.conflicting')}
</span>
: label ('shortcuts.none')}
</td>
</tr>))}
</tbody>
+25 -32
ファイルの表示
@@ -5,19 +5,13 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
formatKeyBinding,
SHORTCUT_DEFINITIONS,
SHORTCUT_SCOPE_LABELS,
} from '@/lib/keyboardShortcuts'
import { formatKeyBinding, SHORTCUT_DEFINITIONS } from '@/lib/keyboardShortcuts'
import { useUITranslation } from '@/lib/useUITranslation'
import type {
KeyBinding,
ShortcutActionId,
ShortcutScope,
} from '@/lib/keyboardShortcuts'
import type { FC } from 'react'
import type { KeyBinding, ShortcutActionId, ShortcutScope } from '@/lib/keyboardShortcuts'
type Props = {
open: boolean
onOpenChange: (open: boolean) => void
@@ -28,16 +22,10 @@ type Props = {
}
const ShortcutHelpDialog: FC<Props> = (
{
open,
onOpenChange,
activeScope,
effectiveBindings,
conflictActionIds,
availableActionIds,
},
) => {
const ShortcutHelpDialog: FC<Props> = props => {
const { open, onOpenChange, activeScope, effectiveBindings,
conflictActionIds, availableActionIds } = props
const { label } = useUITranslation ('settings')
const rows = SHORTCUT_DEFINITIONS
.filter (definition =>
definition.scope === 'global' || definition.scope === activeScope)
@@ -45,11 +33,12 @@ const ShortcutHelpDialog: FC<Props> = (
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl px-6 pb-6 pt-7">
<DialogContent className="max-w-3xl px-6 pb-6 pt-7"
closeLabel={label ('actions.close')}>
<DialogHeader className="pl-8">
<DialogTitle>キーボード・ショートカット</DialogTitle>
<DialogTitle>{label ('shortcuts.title')}</DialogTitle>
<DialogDescription>
この画面で利用できるショートカットを表示します。
{label ('shortcuts.description')}
</DialogDescription>
</DialogHeader>
@@ -57,24 +46,28 @@ const ShortcutHelpDialog: FC<Props> = (
<table className="w-full min-w-[40rem] table-fixed border-collapse text-sm">
<thead className="border-b border-border">
<tr>
<th className="p-2 text-left">操作</th>
<th className="p-2 text-left">適用範囲</th>
<th className="p-2 text-left">キー</th>
<th className="p-2 text-left">競合</th>
<th className="p-2 text-left">{label ('shortcuts.operation')}</th>
<th className="p-2 text-left">{label ('shortcuts.scope')}</th>
<th className="p-2 text-left">{label ('shortcuts.key')}</th>
<th className="p-2 text-left">{label ('shortcuts.conflict')}</th>
</tr>
</thead>
<tbody>
{rows.map (row => (
<tr key={row.id} className="border-b border-border/60 last:border-b-0">
<td className="p-2">{row.label}</td>
<td className="p-2">{SHORTCUT_SCOPE_LABELS[row.scope]}</td>
<td className="p-2">{label (`shortcuts.actions.${row.id}`)}</td>
<td className="p-2">{label (`shortcuts.scopes.${row.scope}`)}</td>
<td className="p-2 font-mono">
{formatKeyBinding (effectiveBindings[row.id])}
{effectiveBindings[row.id] == null
? label ('shortcuts.unassigned')
: <span lang="en">{formatKeyBinding (effectiveBindings[row.id])}</span>}
</td>
<td className="p-2">
{conflictActionIds.has (row.id)
? <span className="text-red-600 dark:text-red-400">競合あり</span>
: 'なし'}
? <span className="text-red-600 dark:text-red-400">
{label ('shortcuts.conflicting')}
</span>
: label ('shortcuts.none')}
</td>
</tr>))}
</tbody>
+18 -13
ファイルの表示
@@ -1,6 +1,4 @@
import type { FC } from 'react'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { useDialogue } from '@/lib/dialogues/useDialogue'
import { Button } from '@/components/ui/button'
import { Dialog,
DialogContent,
@@ -10,6 +8,10 @@ import { Dialog,
DialogTitle } from '@/components/ui/dialog'
import { toast } from '@/components/ui/use-toast'
import { apiPost } from '@/lib/api'
import { useUITranslation } from '@/lib/useUITranslation'
import { rememberUserCode } from '@/lib/users'
import type { FC } from 'react'
import type { User } from '@/types'
@@ -21,18 +23,21 @@ type Props = { visible: boolean
const UserCodeDialogue: FC<Props> = ({ visible, onVisibleChange, user, setUser }) => {
const dialogue = useDialogue ()
const { label } = useUITranslation ('settings')
const handleChange = async () => {
if (!(user))
return
if (!(await dialogue.confirm ({
title: '引継ぎコードを再発行しますか?',
title: label ('userCode.confirm'),
description: (
<div>
<p>再発行するとほかのブラウザからはログアウトされます.</p>
<p>{label ('userCode.logoutWarning')}</p>
</div>),
confirmText: '再発行',
confirmText: label ('userCode.confirmRenew'),
cancelText: label ('actions.cancel'),
closeLabel: label ('actions.close'),
variant: 'danger' })))
return
@@ -40,24 +45,24 @@ const UserCodeDialogue: FC<Props> = ({ visible, onVisibleChange, user, setUser }
{ headers: { 'Content-Type': 'multipart/form-data' } })
if (data.code)
{
localStorage.setItem ('user_code', data.code)
rememberUserCode (data.code)
setUser (user => ({ ...user, inheritanceCode: data.code } as User))
toast ({ title: '再発行しました.' })
toast ({ title: label ('userCode.renewed') })
}
}
return (
<Dialog open={visible} onOpenChange={onVisibleChange}>
<DialogContent className="px-6 pb-6 pt-7">
<DialogContent className="px-6 pb-6 pt-7" closeLabel={label ('actions.close')}>
<DialogHeader className="pl-8">
<DialogTitle>引継ぎコード</DialogTitle>
<DialogTitle>{label ('userCode.title')}</DialogTitle>
<DialogDescription asChild>
<div>
<p>あなたの引継ぎコードはこちらです:</p>
<p>{label ('userCode.description')}</p>
<div className="m-2">{user?.inheritanceCode}</div>
<p className="mt-1 text-sm text-destructive">
このコードはほかの人には教えないでください!
{label ('userCode.secretWarning')}
</p>
</div>
</DialogDescription>
@@ -65,7 +70,7 @@ const UserCodeDialogue: FC<Props> = ({ visible, onVisibleChange, user, setUser }
<DialogFooter>
<Button onClick={handleChange} variant="destructive">
引継ぎコード再発行
{label ('userCode.renew')}
</Button>
</DialogFooter>
</DialogContent>