Reviewed-on: #433 Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
@@ -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>
|
||||
|
||||
@@ -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 ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ('ネコ、チャペ。')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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>)
|
||||
}
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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>)}
|
||||
</>)
|
||||
|
||||
@@ -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 ('虹夏')
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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>)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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')}>
|
||||
その他 »
|
||||
<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'))}>
|
||||
その他 »
|
||||
<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'))}>
|
||||
その他 »
|
||||
<TranslatedText messageKey="more"/>
|
||||
</PrefetchLink>
|
||||
<TopNavUser user={user} sp/>
|
||||
<Separator/>
|
||||
|
||||
@@ -17,7 +17,7 @@ describe ('TopNavUser', () => {
|
||||
|
||||
expect (screen.getByRole ('link', { name: '山田' })).toHaveAttribute (
|
||||
'href',
|
||||
'/users/settings',
|
||||
'/ja/users/settings',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
type Props = { id?: string
|
||||
messages?: string[] }
|
||||
messages?: ReactNode[] }
|
||||
|
||||
|
||||
export const FieldWarning: FC<Props> = ({ id, messages }: Props) => {
|
||||
|
||||
@@ -16,7 +16,7 @@ type Props = {
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void }
|
||||
className?: string
|
||||
label: ReactNode
|
||||
messages?: string[] }
|
||||
messages?: ReactNode[] }
|
||||
|
||||
|
||||
const FormField: FC<Props> = ({ children, checkBox, className, label, messages }: Props) => {
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}>
|
||||
|<
|
||||
</PrefetchLink>
|
||||
<PrefetchLink
|
||||
className="p-2"
|
||||
to={buildTo (page - 1)}
|
||||
aria-label="前のページ">
|
||||
lang={message ('pagination.previous').language}
|
||||
aria-label={message ('pagination.previous').text}>
|
||||
<
|
||||
</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}>
|
||||
>
|
||||
</PrefetchLink>
|
||||
<PrefetchLink
|
||||
className="md:hidden p-2"
|
||||
to={buildTo (totalPages)}
|
||||
aria-label="最後のページ">
|
||||
lang={message ('pagination.last').language}
|
||||
aria-label={message ('pagination.last').text}>
|
||||
>|
|
||||
</PrefetchLink>
|
||||
</>)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import PostCoreDataFields from '@/components/posts/PostCoreDataFields'
|
||||
import PostTextField from '@/components/posts/PostTextField'
|
||||
import TranslatedText from '@/components/TranslatedText'
|
||||
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
import type { PostCoreDataFieldsProps } from '@/components/posts/PostCoreDataFields'
|
||||
|
||||
type TextMessages = string[] | undefined
|
||||
type TextMessages = ReactNode[] | undefined
|
||||
|
||||
type Props = {
|
||||
url: {
|
||||
@@ -29,7 +30,7 @@ const PostCreationDataFields: FC<Props> = (
|
||||
) => (
|
||||
<>
|
||||
<PostTextField
|
||||
label="URL"
|
||||
label={<TranslatedText namespace="shared" messageKey="url"/>}
|
||||
type={url.type}
|
||||
value={url.value}
|
||||
disabled={url.disabled}
|
||||
|
||||
@@ -1,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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)}/>)
|
||||
|
||||
@@ -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 (() => {
|
||||
|
||||
@@ -7,8 +7,11 @@ import PostDurationField from '@/components/posts/PostDurationField'
|
||||
import PostTextField from '@/components/posts/PostTextField'
|
||||
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||
import { hasThumbnailBaseValue, hasVideoTag } from '@/lib/postImportRows'
|
||||
import { renderPostImportMessage } from '@/components/posts/import/postImportRowStatus'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportTypes'
|
||||
@@ -47,9 +50,6 @@ const buildResetDraft = (row: PostImportRow): Draft => ({
|
||||
duration: String (row.resetSnapshot.attributes.duration ?? ''),
|
||||
thumbnailFile: undefined })
|
||||
|
||||
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
|
||||
[...new Set (values.flatMap (value => value ?? []))]
|
||||
|
||||
const sameDraft = (left: Draft, right: Draft): boolean =>
|
||||
left.url === right.url
|
||||
&& left.title === right.title
|
||||
@@ -98,6 +98,11 @@ const PostImportRowForm: FC<Props> = (
|
||||
controls,
|
||||
onSave },
|
||||
) => {
|
||||
const locale = useLocale ()
|
||||
const { label } = useUITranslation ('imports')
|
||||
const groupedMessages = (...values: (string[] | undefined)[]): ReactNode[] =>
|
||||
[...new Set (values.flatMap (value => value ?? []))]
|
||||
.map (value => renderPostImportMessage (value, locale))
|
||||
const [draft, setDraft] = useState<Draft> (() => buildDraft (row))
|
||||
const [messageRow, setMessageRow] = useState<PostImportRow | null> (null)
|
||||
const [saving, setSaving] = useState (false)
|
||||
@@ -144,9 +149,9 @@ const PostImportRowForm: FC<Props> = (
|
||||
return false
|
||||
|
||||
const confirmed = await controls.confirm ({
|
||||
title: '変更をリセットしますか?',
|
||||
confirmText: 'リセット',
|
||||
cancelText: '取消',
|
||||
title: label ('shared:messages.resetChanges'),
|
||||
confirmText: label ('shared:actions.reset'),
|
||||
cancelText: label ('shared:actions.cancel'),
|
||||
variant: 'danger' })
|
||||
if (!(confirmed))
|
||||
return false
|
||||
@@ -156,7 +161,7 @@ const PostImportRowForm: FC<Props> = (
|
||||
setMessageRow (null)
|
||||
setCommittedThumbnailBase (resetDraft.thumbnailBase)
|
||||
return false
|
||||
}, [controls, resetDisabled, resetDraft])
|
||||
}, [controls, resetDisabled, resetDraft, label])
|
||||
|
||||
const save = useCallback (async (): Promise<boolean> => {
|
||||
setSaving (true)
|
||||
@@ -178,16 +183,16 @@ const PostImportRowForm: FC<Props> = (
|
||||
|
||||
useEffect (() => {
|
||||
controls.setActions ([{
|
||||
label: '変更をリセット',
|
||||
label: label ('review.reset'),
|
||||
placement: 'start',
|
||||
variant: 'danger',
|
||||
disabled: resetDisabled,
|
||||
onSelect: reset },
|
||||
{
|
||||
label: '編輯内容を保存',
|
||||
label: label ('review.save'),
|
||||
disabled: saving,
|
||||
onSelect: save }])
|
||||
}, [controls, resetDisabled, reset, save, saving])
|
||||
}, [controls, resetDisabled, reset, save, saving, label])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -210,17 +215,17 @@ const PostImportRowForm: FC<Props> = (
|
||||
value: draft.url,
|
||||
onChange: value => update ('url', value),
|
||||
disabled: saving,
|
||||
warnings: displayRow.fieldWarnings.url,
|
||||
warnings: groupedMessages (displayRow.fieldWarnings.url),
|
||||
errors: groupedMessages (
|
||||
displayRow.validationErrors.url,
|
||||
displayRow.importErrors?.url) }}
|
||||
thumbnailField={
|
||||
<>
|
||||
<PostTextField
|
||||
label="サムネール"
|
||||
label={label ('shared:labels.thumbnail')}
|
||||
value={draft.thumbnailBase}
|
||||
disabled={saving}
|
||||
warnings={currentThumbnailWarnings}
|
||||
warnings={groupedMessages (currentThumbnailWarnings)}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.thumbnailBase,
|
||||
displayRow.importErrors?.thumbnailBase)}
|
||||
@@ -244,7 +249,7 @@ const PostImportRowForm: FC<Props> = (
|
||||
value: draft.title,
|
||||
onChange: value => update ('title', value),
|
||||
disabled: saving,
|
||||
warnings: displayRow.fieldWarnings.title,
|
||||
warnings: groupedMessages (displayRow.fieldWarnings.title),
|
||||
errors: groupedMessages (
|
||||
displayRow.validationErrors.title,
|
||||
displayRow.importErrors?.title) },
|
||||
@@ -270,7 +275,7 @@ const PostImportRowForm: FC<Props> = (
|
||||
value: draft.tags,
|
||||
onChange: value => update ('tags', value),
|
||||
disabled: saving,
|
||||
warnings: displayRow.fieldWarnings.tags,
|
||||
warnings: groupedMessages (displayRow.fieldWarnings.tags),
|
||||
errors: groupedMessages (
|
||||
displayRow.validationErrors.tags,
|
||||
displayRow.importErrors?.tags),
|
||||
@@ -293,9 +298,9 @@ const PostImportRowForm: FC<Props> = (
|
||||
displayRow.validationErrors.videoMs,
|
||||
displayRow.importErrors?.videoMs)}/>)
|
||||
: null}/>
|
||||
<FieldWarning messages={displayRow.baseWarnings}/>
|
||||
<FieldError messages={displayRow.validationErrors.base}/>
|
||||
<FieldError messages={displayRow.importErrors?.base}/>
|
||||
<FieldWarning messages={groupedMessages (displayRow.baseWarnings)}/>
|
||||
<FieldError messages={groupedMessages (displayRow.validationErrors.base)}/>
|
||||
<FieldError messages={groupedMessages (displayRow.importErrors?.base)}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,11 @@ import PostImportTagLinks from '@/components/posts/import/PostImportTagLinks'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
||||
import { displayPostImportStatus,
|
||||
renderPostImportMessage } from '@/components/posts/import/postImportRowStatus'
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import {
|
||||
canEditReviewRow,
|
||||
canRetryResultRow,
|
||||
@@ -14,6 +18,7 @@ import { cn, originalCreatedAtString } from '@/lib/utils'
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { PostImportRow } from '@/lib/postImportTypes'
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
|
||||
type Props = {
|
||||
row: PostImportRow
|
||||
@@ -33,10 +38,10 @@ const summaryWarning = (row: PostImportRow): string | null =>
|
||||
?? row.baseWarnings?.[0]
|
||||
?? null
|
||||
|
||||
const summaryDate = (row: PostImportRow): string =>
|
||||
const summaryDate = (row: PostImportRow, locale: LocaleCode): string =>
|
||||
originalCreatedAtString (
|
||||
row.attributes.originalCreatedFrom?.toString () ?? null,
|
||||
row.attributes.originalCreatedBefore?.toString () ?? null)
|
||||
row.attributes.originalCreatedBefore?.toString () ?? null, locale)
|
||||
|
||||
|
||||
const PostImportRowSummary: FC<Props> = (
|
||||
@@ -52,6 +57,10 @@ const PostImportRowSummary: FC<Props> = (
|
||||
showActions = true,
|
||||
showSkipToggle = false },
|
||||
) => {
|
||||
const locale = useLocale ()
|
||||
const { label } = useUITranslation ('imports')
|
||||
const localisedRowMessages = rowMessages?.map (value =>
|
||||
renderPostImportMessage (value, locale))
|
||||
const warning = summaryWarning (row)
|
||||
const displayStatus = displayPostImportStatus (row)
|
||||
const editVisible = onEdit != null
|
||||
@@ -69,7 +78,7 @@ const PostImportRowSummary: FC<Props> = (
|
||||
checked={skipChecked}
|
||||
onChange={event => onToggleSkip?.(event.target.checked)}
|
||||
disabled={skipDisabled === true}/>
|
||||
<span>スキップ</span>
|
||||
<span>{label ('shared:actions.skip')}</span>
|
||||
</label>)
|
||||
: null
|
||||
|
||||
@@ -96,17 +105,17 @@ const PostImportRowSummary: FC<Props> = (
|
||||
</div>
|
||||
<PostImportTagLinks tags={row.displayTags}/>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{summaryDate (row)}
|
||||
<LocalisedText language={locale}>{summaryDate (row, locale)}</LocalisedText>
|
||||
</div>
|
||||
{showDuration && (
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
動画時間 {duration}
|
||||
{label ('review.duration', { duration })}
|
||||
</div>)}
|
||||
{warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||
{warning}
|
||||
{renderPostImportMessage (warning, locale)}
|
||||
</div>)}
|
||||
<FieldError messages={rowMessages}/>
|
||||
<FieldError messages={localisedRowMessages}/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
||||
@@ -120,7 +129,7 @@ const PostImportRowSummary: FC<Props> = (
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={editDisabled === true || !(editAllowed)}>
|
||||
編輯
|
||||
{label ('shared:actions.edit')}
|
||||
</Button>)}
|
||||
{showActions && retryAllowed && (
|
||||
<Button
|
||||
@@ -128,7 +137,7 @@ const PostImportRowSummary: FC<Props> = (
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
disabled={retryDisabled === true}>
|
||||
再試行
|
||||
{label ('shared:actions.retry')}
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,17 +165,17 @@ const PostImportRowSummary: FC<Props> = (
|
||||
</div>
|
||||
<PostImportTagLinks tags={row.displayTags}/>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{summaryDate (row)}
|
||||
<LocalisedText language={locale}>{summaryDate (row, locale)}</LocalisedText>
|
||||
</div>
|
||||
{showDuration && (
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
動画時間 {duration}
|
||||
{label ('review.duration', { duration })}
|
||||
</div>)}
|
||||
{warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||
{warning}
|
||||
{renderPostImportMessage (warning, locale)}
|
||||
</div>)}
|
||||
<FieldError messages={rowMessages}/>
|
||||
<FieldError messages={localisedRowMessages}/>
|
||||
{skipControl}
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,7 +188,7 @@ const PostImportRowSummary: FC<Props> = (
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={editDisabled === true || !(editAllowed)}>
|
||||
編輯
|
||||
{label ('shared:actions.edit')}
|
||||
</Button>)}
|
||||
{retryAllowed && (
|
||||
<Button
|
||||
@@ -188,7 +197,7 @@ const PostImportRowSummary: FC<Props> = (
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
disabled={retryDisabled === true}>
|
||||
再試行
|
||||
{label ('shared:actions.retry')}
|
||||
</Button>)}
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
@@ -1,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
|
||||
|
||||
@@ -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 (() => {
|
||||
|
||||
@@ -12,7 +12,7 @@ type Props = {
|
||||
const PostImportThumbnailPreview: FC<Props> = (
|
||||
{ url,
|
||||
file,
|
||||
alt = 'サムネール',
|
||||
alt,
|
||||
className = 'h-16 w-16' },
|
||||
) => (
|
||||
<PostThumbnailPreview
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { createElement } from 'react'
|
||||
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import { i18n, translateUIMessage } from '@/lib/i18n'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { PostImportRow } from '@/lib/postImportTypes'
|
||||
|
||||
export type PostImportDisplayStatus =
|
||||
@@ -10,6 +17,40 @@ export type PostImportDisplayStatus =
|
||||
|
||||
export type PostImportBadgeValue = PostImportDisplayStatus
|
||||
|
||||
const IMPORT_MESSAGE_KEYS = [
|
||||
'validation.duplicateUrl', 'validation.processingFailed',
|
||||
'validation.tooMany', 'validation.urlTooLong', 'validation.invalidUrl',
|
||||
'validation.protocol', 'validation.duplicateRow', 'storage.readFailed',
|
||||
'storage.writeFailed', 'storage.clearFailed', 'source.urlRequired',
|
||||
'shared:labels.thumbnailMissing']
|
||||
|
||||
|
||||
// Keep stored and compared messages intact; translate only recognised UI messages at display time.
|
||||
export const renderPostImportMessage = (value: string, locale: string): ReactNode => {
|
||||
for (const reference of IMPORT_MESSAGE_KEYS)
|
||||
{
|
||||
const [namespace, key] =
|
||||
reference.includes (':')
|
||||
? reference.split (':')
|
||||
: ['imports', reference]
|
||||
const template: unknown = i18n.getResource ('ja', namespace, key)
|
||||
if (typeof template !== 'string')
|
||||
continue
|
||||
const names = [...template.matchAll (/\{\{(\w+)\}\}/g)].map (match => match[1])
|
||||
const pattern = template.split (/(\{\{\w+\}\})/).map (part =>
|
||||
part.startsWith ('{{')
|
||||
? '(\\d+)'
|
||||
: part.replace (/[.*+?^${}()|[\]\\]/g, '\\$&')).join ('')
|
||||
const match = value.match (new RegExp (`^${pattern}$`))
|
||||
if (match == null)
|
||||
continue
|
||||
const values = Object.fromEntries (names.map ((name, index) => [name, match[index + 1]]))
|
||||
const result = translateUIMessage (key, namespace, locale, values)
|
||||
return createElement (LocalisedText, { language: result.language, children: result.text })
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const hasWarnings = (row: PostImportRow): boolean =>
|
||||
Object.values (row.fieldWarnings ?? { }).some (messages => messages.length > 0)
|
||||
|| row.baseWarnings.length > 0
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import { translateUIMessage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする