Reviewed-on: #433 Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
@@ -63,7 +63,7 @@ describe ('PostDetailPage', () => {
|
||||
renderPage ()
|
||||
|
||||
await waitFor (() => {
|
||||
expect (postsApi.fetchPost).toHaveBeenCalledWith ('9')
|
||||
expect (postsApi.fetchPost).toHaveBeenCalledWith ('9', 'ja')
|
||||
})
|
||||
expect (await screen.findByText ('Embed:https://example.com/9')).toBeInTheDocument ()
|
||||
expect (screen.getByRole ('button', { name: '未閲覧' })).toBeInTheDocument ()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import PostEditForm from '@/components/PostEditForm'
|
||||
import PostEmbed from '@/components/PostEmbed'
|
||||
import PostList from '@/components/PostList'
|
||||
@@ -11,13 +11,14 @@ import TabGroup, { Tab } from '@/components/common/TabGroup'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import PageHead from '@/components/common/PageHead'
|
||||
import { isApiError } from '@/lib/api'
|
||||
import { fetchPost, toggleViewedFlg } from '@/lib/posts'
|
||||
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
import ServiceUnavailable from '@/pages/ServiceUnavailable'
|
||||
|
||||
@@ -29,6 +30,8 @@ type Props = { user: User | null }
|
||||
|
||||
|
||||
const PostDetailPage: FC<Props> = ({ user }) => {
|
||||
const locale = useLocale ()
|
||||
const { message, label } = useUITranslation ('posts')
|
||||
const editable = canEditContent (user)
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal'
|
||||
@@ -42,18 +45,18 @@ const PostDetailPage: FC<Props> = ({ user }) => {
|
||||
? { duration: .08, ease: 'linear' as const }
|
||||
: { duration: .2, ease: 'easeOut' as const }
|
||||
const postId = String (id ?? '')
|
||||
const postKey = postsKeys.show (postId)
|
||||
const postKey = postsKeys.show (postId, locale)
|
||||
|
||||
const { data: post, isError: errorFlg, error } = useQuery ({
|
||||
enabled: Boolean (id),
|
||||
queryKey: postKey,
|
||||
queryFn: () => fetchPost (postId) })
|
||||
queryFn: () => fetchPost (postId, locale) })
|
||||
|
||||
const qc = useQueryClient ()
|
||||
|
||||
const embedRef = useRef<NiconicoViewerHandle> (null)
|
||||
|
||||
const [status, setStatus] = useState (200)
|
||||
const status = (errorFlg && isApiError (error)) ? error.response?.status ?? 200 : 200
|
||||
|
||||
const changeViewedFlg = useMutation ({
|
||||
mutationFn: async (next: boolean) => {
|
||||
@@ -70,24 +73,15 @@ const PostDetailPage: FC<Props> = ({ user }) => {
|
||||
onError: (...[, , ctx]) => {
|
||||
if (ctx?.prev)
|
||||
qc.setQueryData (postKey, ctx.prev)
|
||||
toast ({ title: '失敗……', description: '通信に失敗しました……' })
|
||||
toast ({ title: label ('shared:messages.failed'),
|
||||
description: label ('detail.networkFailed') })
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries ({ queryKey: postsKeys.root })} })
|
||||
|
||||
useEffect (() => {
|
||||
if (!(errorFlg))
|
||||
return
|
||||
|
||||
const code = isApiError (error) ? error.response?.status : undefined
|
||||
if (code)
|
||||
setStatus (code)
|
||||
}, [errorFlg, error])
|
||||
|
||||
useEffect (() => {
|
||||
scroll (0, 0)
|
||||
|
||||
setStatus (200)
|
||||
}, [id])
|
||||
|
||||
switch (status)
|
||||
@@ -106,11 +100,10 @@ const PostDetailPage: FC<Props> = ({ user }) => {
|
||||
<div
|
||||
data-sidebar-container
|
||||
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
|
||||
<Helmet>
|
||||
<PageHead title={post?.title || post?.url || message ('navigation:posts')}>
|
||||
{(post?.thumbnail || post?.thumbnailBase) && (
|
||||
<meta name="thumbnail" content={post.thumbnail! || post.thumbnailBase!}/>)}
|
||||
{post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>}
|
||||
</Helmet>
|
||||
</PageHead>
|
||||
|
||||
{post && <TagDetailSidebar post={post} className="hidden md:block"/>}
|
||||
|
||||
@@ -120,7 +113,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
|
||||
<>
|
||||
{(post.childPosts ?? []).length > 0 && (
|
||||
<div className="mb-4 bg-green-200 dark:bg-green-800 text-sm p-2 rounded-md">
|
||||
<p>この投稿には {post.childPosts!.length} 件の子投稿があります.</p>
|
||||
<p>{label ('detail.children', { count: post.childPosts!.length })}</p>
|
||||
<PostList posts={[{ ...post, childPosts: [{ } as Post] },
|
||||
...post.childPosts!.map (p => ({
|
||||
...p, parentPosts: [{ } as Post] }))]}/>
|
||||
@@ -135,9 +128,9 @@ const PostDetailPage: FC<Props> = ({ user }) => {
|
||||
key={pp.id}
|
||||
className="mb-4 bg-yellow-200 dark:bg-yellow-800 text-sm p-2 rounded-md">
|
||||
<p>
|
||||
この投稿には 1 件の親投稿{
|
||||
siblings.length > 1
|
||||
&& `と ${ siblings.length - 1 } 件の姉妹投稿`}があります.
|
||||
{siblings.length > 1
|
||||
? label ('detail.parentAndSiblings', { count: siblings.length - 1 })
|
||||
: label ('detail.parent')}
|
||||
</p>
|
||||
<PostList posts={[{ ...pp, childPosts: [{ } as Post] },
|
||||
...siblings.map (p => ({
|
||||
@@ -168,20 +161,20 @@ const PostDetailPage: FC<Props> = ({ user }) => {
|
||||
<Button onClick={() => changeViewedFlg.mutate (!(post.viewed))}
|
||||
disabled={changeViewedFlg.isPending}
|
||||
className={cn ('text-white', viewedClass)}>
|
||||
{post.viewed ? '閲覧済' : '未閲覧'}
|
||||
{label (post.viewed ? 'detail.viewed' : 'detail.unviewed')}
|
||||
</Button>
|
||||
<TabGroup>
|
||||
<Tab name="関聯">
|
||||
<Tab name={label ('shared:labels.relations')}>
|
||||
{post.related.length > 0
|
||||
? <PostList posts={post.related}/>
|
||||
: 'まだないよ(笑)'}
|
||||
: label ('detail.emptyRelated')}
|
||||
</Tab>
|
||||
{editable && (
|
||||
<Tab name="編輯">
|
||||
<Tab name={label ('shared:actions.edit')}>
|
||||
<PostEditForm
|
||||
post={post}
|
||||
onSave={newPost => {
|
||||
qc.setQueryData (postsKeys.show (postId),
|
||||
qc.setQueryData (postsKeys.show (postId, locale),
|
||||
(prev: Post | undefined) => newPost ?? prev)
|
||||
qc.invalidateQueries ({ queryKey: postsKeys.root })
|
||||
qc.invalidateQueries ({ queryKey: tagsKeys.root })
|
||||
@@ -189,7 +182,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
|
||||
</Tab>)}
|
||||
</TabGroup>
|
||||
</>)
|
||||
: 'Loading...'}
|
||||
: label ('common:loading')}
|
||||
</MainArea>
|
||||
|
||||
{post && <TagDetailSidebar post={post} sp className="md:hidden"/>}
|
||||
|
||||
@@ -47,10 +47,10 @@ describe ('PostHistoryPage', () => {
|
||||
externalTag: '7',
|
||||
page: 1,
|
||||
limit: 20,
|
||||
})
|
||||
}, 'ja')
|
||||
})
|
||||
|
||||
expect (tagsApi.fetchExternalTag).toHaveBeenCalledWith ('7')
|
||||
expect (tagsApi.fetchExternalTag).toHaveBeenCalledWith ('7', 'ja')
|
||||
expect (tagsApi.fetchTag).not.toHaveBeenCalled ()
|
||||
|
||||
expect (
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import TagLink from '@/components/TagLink'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import Pagination from '@/components/common/Pagination'
|
||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
||||
import { useDialogue } from '@/lib/dialogues/useDialogue'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import PageHead from '@/components/common/PageHead'
|
||||
import { clientAnimationTransition,
|
||||
clientScrollBehaviour } from '@/lib/clientAnimation'
|
||||
import { fetchPostChanges, updatePost } from '@/lib/posts'
|
||||
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
|
||||
import { fetchExternalTag, fetchTag } from '@/lib/tags'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { cn, dateString, originalCreatedAtString } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import { cn, localisedDateString, originalCreatedAtString } from '@/lib/utils'
|
||||
|
||||
import type { FC, MouseEvent } from 'react'
|
||||
|
||||
@@ -39,6 +40,8 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => (
|
||||
|
||||
|
||||
const PostHistoryPage: FC = () => {
|
||||
const { message, label } = useUITranslation ('history')
|
||||
const locale = useLocale ()
|
||||
const dialogue = useDialogue ()
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||
@@ -61,29 +64,30 @@ const PostHistoryPage: FC = () => {
|
||||
const tagQueryId = tagId ?? ''
|
||||
const { data: tag } = useQuery ({
|
||||
enabled: Boolean (tagId),
|
||||
queryKey: tagsKeys.show (tagQueryId),
|
||||
queryFn: () => fetchTag (tagQueryId) })
|
||||
queryKey: tagsKeys.show (tagQueryId, locale),
|
||||
queryFn: () => fetchTag (tagQueryId, locale) })
|
||||
|
||||
const externalTagQueryId = externalTagId ?? ''
|
||||
const { data: externalTag } = useQuery ({
|
||||
enabled: Boolean (externalTagId),
|
||||
queryKey: tagsKeys.externalShow (externalTagQueryId),
|
||||
queryFn: () => fetchExternalTag (externalTagQueryId) })
|
||||
queryKey: tagsKeys.externalShow (externalTagQueryId, locale),
|
||||
queryFn: () => fetchExternalTag (externalTagQueryId, locale) })
|
||||
|
||||
const { data, isLoading: loading } = useQuery ({
|
||||
const { data, isLoading: loading, isError } = useQuery ({
|
||||
queryKey: postsKeys.changes ({ ...(id && { post: id }),
|
||||
...(tagId && { tag: tagId }),
|
||||
...(externalTagId && { externalTag: externalTagId }),
|
||||
page, limit }),
|
||||
page, limit }, locale),
|
||||
queryFn: () => fetchPostChanges ({ ...(id && { post: id }),
|
||||
...(tagId && { tag: tagId }),
|
||||
...(externalTagId && {
|
||||
externalTag: externalTagId }),
|
||||
page, limit }) })
|
||||
page, limit }, locale) })
|
||||
const changes = data?.versions ?? []
|
||||
const totalPages = data ? Math.ceil (data.count / limit) : 0
|
||||
const displayedTag = externalTag ?? tag
|
||||
const pageTitleLabel = `耕作履歴${ id ? `: 投稿 #${ id }` : '' }${
|
||||
const pageTitleLabel = `${message ('postTitle').text}${
|
||||
id ? `${message ('postPrefix').text}#${id}` : '' }${
|
||||
displayedTag ? `(${ displayedTag.name })` : '' }`
|
||||
|
||||
const qc = useQueryClient ()
|
||||
@@ -92,27 +96,24 @@ const PostHistoryPage: FC = () => {
|
||||
e.preventDefault ()
|
||||
|
||||
if (!(await dialogue.confirm ({
|
||||
title: '差戻の確認',
|
||||
description: `『${ change.title.current || change.url.current }』を版 ${
|
||||
change.versionNo } に差戻します.\nよろしいですか?`,
|
||||
confirmText: '差戻' })))
|
||||
title: label ('confirmTitle'),
|
||||
description: label ('confirmPost', {
|
||||
name: change.title.current || change.url.current, version: change.versionNo }),
|
||||
confirmText: label ('revert'),
|
||||
cancelText: label ('settings:actions.cancel'),
|
||||
closeLabel: label ('settings:actions.close') })))
|
||||
return
|
||||
|
||||
try
|
||||
{
|
||||
const id = change.postId
|
||||
const title = change.title.current
|
||||
const tags =
|
||||
change.tags
|
||||
.filter (t => t.type !== 'removed')
|
||||
.map (t => t.name)
|
||||
.filter (t => t.slice (0, 5) !== 'nico:')
|
||||
.join (' ')
|
||||
const parentPostIds =
|
||||
(change.parentPosts ?? [])
|
||||
.filter (p => p.type !== 'removed')
|
||||
.map (p => p.id)
|
||||
.join (' ')
|
||||
const currentTags = change.tags.filter (tag => tag.type !== 'removed' && !(tag.external))
|
||||
if (currentTags.some (tag => tag.restoreName == null)
|
||||
|| typeof change.parentPostIds !== 'string')
|
||||
throw new Error ()
|
||||
const tags = currentTags.map (tag => tag.restoreName).join (' ')
|
||||
const parentPostIds = change.parentPostIds
|
||||
const duration =
|
||||
change.videoMs.current == null
|
||||
? null
|
||||
@@ -121,16 +122,16 @@ const PostHistoryPage: FC = () => {
|
||||
const originalCreatedBefore = change.originalCreatedBefore.current
|
||||
await updatePost ({ id, title, tags, duration, parentPostIds,
|
||||
originalCreatedFrom, originalCreatedBefore },
|
||||
{ force: true })
|
||||
{ force: true }, locale)
|
||||
|
||||
qc.invalidateQueries ({ queryKey: postsKeys.root })
|
||||
qc.invalidateQueries ({ queryKey: tagsKeys.root })
|
||||
|
||||
toast ({ description: '差戻しました.' })
|
||||
toast ({ description: label ('restored') })
|
||||
}
|
||||
catch
|
||||
{
|
||||
toast ({ description: '差戻に失敗……' })
|
||||
toast ({ description: label ('restoreFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,13 +144,14 @@ const PostHistoryPage: FC = () => {
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`耕作履歴 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
<PageHead title={message ('postTitle')}/>
|
||||
|
||||
<PageTitle aria-label={pageTitleLabel}>
|
||||
耕作履歴
|
||||
{id && <>: 投稿 {<PrefetchLink to={`/posts/${ id }`}>#{id}</PrefetchLink>}</>}
|
||||
<PageTitle aria-label={pageTitleLabel} lang={message ('postTitle').language}>
|
||||
{label ('postTitle')}
|
||||
{id && (
|
||||
<>{label ('postPrefix')}
|
||||
<PrefetchLink to={`/posts/${ id }`}>#{id}</PrefetchLink>
|
||||
</>)}
|
||||
{displayedTag && (
|
||||
<>(<TagLink
|
||||
tag={displayedTag}
|
||||
@@ -157,7 +159,8 @@ const PostHistoryPage: FC = () => {
|
||||
withCount={false}/>)</>)}
|
||||
</PageTitle>
|
||||
|
||||
{loading ? 'Loading...' : (
|
||||
{isError && <p role="alert">{label ('common:fetchError')}</p>}
|
||||
{loading ? label ('common:loading') : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1200px] table-fixed border-collapse">
|
||||
@@ -184,15 +187,15 @@ const PostHistoryPage: FC = () => {
|
||||
|
||||
<thead className="border-b-2 border-black dark:border-white">
|
||||
<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">URL</th>
|
||||
<th className="p-2 text-left">タグ</th>
|
||||
<th className="p-2 text-left">{label ('post')}</th>
|
||||
<th className="p-2 text-left">{label ('version')}</th>
|
||||
<th className="p-2 text-left">{label ('shared:labels.title')}</th>
|
||||
<th className="p-2 text-left">{label ('shared:url')}</th>
|
||||
<th className="p-2 text-left">{label ('navigation:tags')}</th>
|
||||
{/* TODO: 親投稿の履歴 */}
|
||||
{/* <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 ('originalPostedAt')}</th>
|
||||
<th className="p-2 text-left">{label ('tags:updatedAt')}</th>
|
||||
<th className="p-2"/>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -284,32 +287,34 @@ const PostHistoryPage: FC = () => {
|
||||
{pp.title}
|
||||
</span>))))}
|
||||
</td> */}
|
||||
<td className="p-2">
|
||||
<td className="p-2"
|
||||
lang={message ('unknownDate').language}>
|
||||
{change.versionNo === 1
|
||||
? originalCreatedAtString (change.originalCreatedFrom.current,
|
||||
change.originalCreatedBefore.current)
|
||||
? originalCreatedAtString (
|
||||
change.originalCreatedFrom.current,
|
||||
change.originalCreatedBefore.current, locale)
|
||||
: renderDiff ({
|
||||
current: originalCreatedAtString (
|
||||
change.originalCreatedFrom.current,
|
||||
change.originalCreatedBefore.current),
|
||||
change.originalCreatedFrom.current,
|
||||
change.originalCreatedBefore.current, locale),
|
||||
prev: originalCreatedAtString (
|
||||
change.originalCreatedFrom.prev,
|
||||
change.originalCreatedBefore.prev) })}
|
||||
change.originalCreatedFrom.prev,
|
||||
change.originalCreatedBefore.prev, locale) })}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{change.createdByUser
|
||||
? (
|
||||
<PrefetchLink to={`/users/${ change.createdByUser.id }`}>
|
||||
{change.createdByUser.name
|
||||
|| `名もなきニジラー(#${ change.createdByUser.id })`}
|
||||
|| label ('anonymous', { id: change.createdByUser.id })}
|
||||
</PrefetchLink>)
|
||||
: 'bot 操作'}
|
||||
: label ('bot')}
|
||||
<br/>
|
||||
{dateString (change.createdAt)}
|
||||
{localisedDateString (change.createdAt, locale)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<a href="#" onClick={async e => await handleRevert (e, change)}>
|
||||
復元
|
||||
{label ('shared:actions.restore')}
|
||||
</a>
|
||||
</td>
|
||||
</tr>)
|
||||
|
||||
@@ -130,7 +130,9 @@ describe ('PostImportReviewPage', () => {
|
||||
|
||||
renderReviewPage (['https://example.com/post'])
|
||||
|
||||
fireEvent.click (await screen.findByRole ('button', { name: '追加' }))
|
||||
const submit = await screen.findByRole ('button', { name: '追加' })
|
||||
await waitFor (() => expect (submit).toBeEnabled ())
|
||||
fireEvent.click (submit)
|
||||
|
||||
expect (await screen.findAllByText ('登録失敗')).toHaveLength (2)
|
||||
expect (screen.getAllByText ('invalid title')).toHaveLength (2)
|
||||
@@ -147,11 +149,14 @@ describe ('PostImportReviewPage', () => {
|
||||
|
||||
renderReviewPage (['https://example.com/post'])
|
||||
|
||||
fireEvent.click (await screen.findByRole ('button', { name: '追加' }))
|
||||
const submit = await screen.findByRole ('button', { name: '追加' })
|
||||
await waitFor (() => expect (submit).toBeEnabled ())
|
||||
fireEvent.click (submit)
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByLabelText ('current-location')).toHaveTextContent ('/posts')
|
||||
expect (screen.getByLabelText ('current-location').textContent).toBe ('/ja/posts')
|
||||
})
|
||||
expect (api.apiPost).toHaveBeenCalledWith ('/posts/bulk', expect.any (FormData))
|
||||
expect (api.apiPost).toHaveBeenCalledWith (
|
||||
'/posts/bulk', expect.any (FormData), { locale: 'ja' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
||||
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
|
||||
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
|
||||
import { renderPostImportMessage } from '@/components/posts/import/postImportRowStatus'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import PageHead from '@/components/common/PageHead'
|
||||
import { apiGet, apiPost, isApiError } from '@/lib/api'
|
||||
import { translateUIMessage } from '@/lib/i18n'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { parsePostNewReviewUrls } from '@/lib/postNewQueryState'
|
||||
import { validateImportSource } from '@/lib/postImportSourceValidation'
|
||||
@@ -99,8 +102,9 @@ type ExistingSkippedRowsProps = {
|
||||
id: string
|
||||
rows: PostImportRow[] }
|
||||
|
||||
const DUPLICATE_URL_MESSAGE = 'URL が重複しています.'
|
||||
const BULK_PROCESSING_FAILED_MESSAGE = '登録中にエラーが発生しました.'
|
||||
const DUPLICATE_URL_MESSAGE = translateUIMessage ('validation.duplicateUrl', 'imports', 'ja').text
|
||||
const BULK_PROCESSING_FAILED_MESSAGE =
|
||||
translateUIMessage ('validation.processingFailed', 'imports', 'ja').text
|
||||
|
||||
const emptyAttributes = () => ({
|
||||
title: '',
|
||||
@@ -595,10 +599,12 @@ const editableRowDirty = (row: PostImportRow): boolean =>
|
||||
|
||||
|
||||
const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
const locale = useLocale ()
|
||||
const { message, label } = useUITranslation ('imports')
|
||||
const editable = canEditContent (user)
|
||||
const dialogue = useDialogue ()
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
const navigate = useLocaleNavigate ()
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||
const existingRowsTransition =
|
||||
@@ -608,7 +614,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
? { duration: .08, ease: 'linear' as const }
|
||||
: { duration: .2, ease: 'easeOut' as const }
|
||||
|
||||
const [rows, setRows] = useState<PostImportRow[] | null> (null)
|
||||
const [rows, setRows] = useState<PostImportRow[] | null> (
|
||||
() => buildInitialRows (parsePostNewReviewUrls (location.search)))
|
||||
const [metadataLoading, setMetadataLoading] = useState (false)
|
||||
const [submitting, setSubmitting] = useState (false)
|
||||
const [busyRowIds, setBusyRowIds] = useState<Set<number>> (new Set ())
|
||||
@@ -649,9 +656,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
const finishImport = useCallback (() => {
|
||||
allowNextNavigation ()
|
||||
clearPostImportSourceDraft (message =>
|
||||
toast ({ title: '入力内容を削除できませんでした', description: message }))
|
||||
toast ({ title: label ('review.clearFailed'),
|
||||
description: renderPostImportMessage (message, locale) }))
|
||||
navigate ('/posts')
|
||||
}, [allowNextNavigation, navigate])
|
||||
}, [allowNextNavigation, navigate, label, locale])
|
||||
|
||||
useEffect (() => {
|
||||
const urls = parsePostNewReviewUrls (location.search)
|
||||
@@ -696,7 +704,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
|
||||
try
|
||||
{
|
||||
const preview = await apiGet<PostMetadataResponse> ('/posts/metadata', {
|
||||
const preview = await apiGet<PostMetadataResponse> ('/posts/metadata', { locale,
|
||||
params: { url: currentRow.url },
|
||||
signal: controller.signal })
|
||||
if (!(active) || metadataSequenceRef.current !== sequence)
|
||||
@@ -770,7 +778,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
active = false
|
||||
controllers.forEach (controller => controller.abort ())
|
||||
}
|
||||
}, [commitRows, currentRowBySource, location.search, updateRows])
|
||||
}, [commitRows, currentRowBySource, location.search, updateRows, locale])
|
||||
|
||||
useEffect (() => {
|
||||
if (editingRow == null || resultRepairMode (rowsRef.current) !== 'failed')
|
||||
@@ -871,12 +879,12 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
urlChanged
|
||||
? mergePreviewRow (
|
||||
nextRow,
|
||||
await apiGet<PostMetadataResponse> ('/posts/metadata', {
|
||||
await apiGet<PostMetadataResponse> ('/posts/metadata', { locale,
|
||||
params: { url: nextRow.url } }))
|
||||
: nextRow
|
||||
const dryRun = await apiPost<PostMetadataResponse> (
|
||||
'/posts?dry=1',
|
||||
buildDryRunFormData (candidateRow))
|
||||
buildDryRunFormData (candidateRow), { locale })
|
||||
const mergedRow = mergeDryRunRow (candidateRow, dryRun)
|
||||
const mergedRows = updateRows (currentRows =>
|
||||
replaceImportRow (currentRows, mergedRow))
|
||||
@@ -910,7 +918,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
return { saved: false, row: applyThumbnailWarning (errorRow) }
|
||||
}
|
||||
|
||||
toast ({ title: '行の再検証に失敗しました' })
|
||||
toast ({ title: label ('review.validateFailed') })
|
||||
return { saved: false, row: null }
|
||||
}
|
||||
}
|
||||
@@ -924,8 +932,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
saveDraft (row, { draft, resetRequested })
|
||||
|
||||
await dialogue.form ({
|
||||
title: '投稿を編輯',
|
||||
cancelText: '取消',
|
||||
title: label ('review.editPost'),
|
||||
cancelText: label ('shared:actions.cancel'),
|
||||
size: 'large',
|
||||
body: controls => (
|
||||
<PostImportRowForm
|
||||
@@ -974,11 +982,11 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
|
||||
const result = await apiPost<{ results: BulkApiRow[] }>(
|
||||
'/posts/bulk',
|
||||
buildBulkFormData ([target]))
|
||||
buildBulkFormData ([target]), { locale })
|
||||
if (result.results.length !== 1)
|
||||
{
|
||||
updateRows (currentRows => replaceImportRow (currentRows, originalRow))
|
||||
toast ({ title: '登録結果が不完全でした' })
|
||||
toast ({ title: label ('review.incomplete') })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -993,7 +1001,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
catch
|
||||
{
|
||||
updateRows (currentRows => replaceImportRow (currentRows, originalRow))
|
||||
toast ({ title: '再試行に失敗しました' })
|
||||
toast ({ title: label ('review.retryFailed') })
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1018,10 +1026,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
{
|
||||
const result = await apiPost<{ results: BulkApiRow[] }>(
|
||||
'/posts/bulk',
|
||||
buildBulkFormData (latestProcessingRows))
|
||||
buildBulkFormData (latestProcessingRows), { locale })
|
||||
if (result.results.length !== latestProcessingRows.length)
|
||||
{
|
||||
toast ({ title: '登録結果が不完全でした' })
|
||||
toast ({ title: label ('review.incomplete') })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1035,7 +1043,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
}
|
||||
catch
|
||||
{
|
||||
toast ({ title: '登録に失敗しました' })
|
||||
toast ({ title: label ('review.submitFailed') })
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1051,13 +1059,11 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{`追加内容確認 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
<PageHead title={message ('review.title')}/>
|
||||
|
||||
<MainArea className="pb-40">
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<PageTitle>追加内容確認</PageTitle>
|
||||
<PageTitle>{label ('review.title')}</PageTitle>
|
||||
|
||||
{existingRows.length > 0 && (
|
||||
<div className="rounded-lg border p-4">
|
||||
@@ -1076,7 +1082,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
? ''
|
||||
: 'transition-transform')}/>
|
||||
</span>
|
||||
既存投稿による自動スキップ {counts.existingSkipped}件
|
||||
{label ('review.existingSkipped', { count: counts.existingSkipped })}
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{showExistingRows && (
|
||||
@@ -1154,36 +1160,39 @@ const PostImportFooter = (
|
||||
pendingOrErrorCount: number
|
||||
onBack: () => void
|
||||
onSubmit: () => void },
|
||||
) => (
|
||||
<div
|
||||
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
|
||||
dark:border-neutral-700 dark:bg-neutral-950/95">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
|
||||
md:items-center md:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>作成対象 {creatableCount}件</span>
|
||||
<span>手動スキップ {manualSkippedCount}件</span>
|
||||
<span>既存投稿による自動スキップ {existingSkippedCount}件</span>
|
||||
<span>未処理 {pendingOrErrorCount}件</span>
|
||||
) => {
|
||||
const { label } = useUITranslation ('imports')
|
||||
return (
|
||||
<div
|
||||
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
|
||||
dark:border-neutral-700 dark:bg-neutral-950/95">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
|
||||
md:items-center md:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>{label ('review.creatable', { count: creatableCount })}</span>
|
||||
<span>{label ('review.manualSkipped', { count: manualSkippedCount })}</span>
|
||||
<span>{label ('review.existingSkipped', { count: existingSkippedCount })}</span>
|
||||
<span>{label ('review.pending', { count: pendingOrErrorCount })}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full md:w-auto"
|
||||
onClick={onBack}
|
||||
disabled={submitting}>
|
||||
{label ('review.back')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full md:w-auto"
|
||||
onClick={onSubmit}
|
||||
disabled={metadataLoading || submitting || busyRowsPresent || !(canSubmit)}>
|
||||
{creatableCount > 1 ? label ('review.addAll') : label ('shared:actions.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full md:w-auto"
|
||||
onClick={onBack}
|
||||
disabled={submitting}>
|
||||
戻る
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full md:w-auto"
|
||||
onClick={onSubmit}
|
||||
disabled={metadataLoading || submitting || busyRowsPresent || !(canSubmit)}>
|
||||
{creatableCount > 1 ? '一括追加' : '追加'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>)
|
||||
</div>)
|
||||
}
|
||||
|
||||
export default PostImportReviewPage
|
||||
|
||||
@@ -36,7 +36,8 @@ describe ('PostImportSourcePage', () => {
|
||||
target: { value: '\nftp://example.com/file\nhttps://example.com/valid' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||
|
||||
expect (screen.getByText (/2 行目: HTTP または HTTPS/)).toBeInTheDocument ()
|
||||
expect (screen.getByText ('ftp://example.com/file').closest ('li'))
|
||||
.toHaveTextContent ('2 行目: HTTP または HTTPS')
|
||||
expect (screen.getByText ('ftp://example.com/file')).toBeInTheDocument ()
|
||||
expect (input).toHaveAttribute ('aria-invalid', 'true')
|
||||
expect (router.navigate).not.toHaveBeenCalled ()
|
||||
@@ -51,8 +52,8 @@ describe ('PostImportSourcePage', () => {
|
||||
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||
|
||||
expect (router.navigate).toHaveBeenCalledWith (
|
||||
'/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba'
|
||||
+ '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc')
|
||||
'/ja/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba'
|
||||
+ '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc', undefined)
|
||||
})
|
||||
|
||||
it ('disables Next when the encoded request target reaches 4096 bytes', () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
|
||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import { renderPostImportMessage } from '@/components/posts/import/postImportRowStatus'
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import Form from '@/components/common/Form'
|
||||
import FormField from '@/components/common/FormField'
|
||||
@@ -10,7 +12,7 @@ import TextArea from '@/components/common/TextArea'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import PageHead from '@/components/common/PageHead'
|
||||
import {
|
||||
buildPostNewReviewPath,
|
||||
isPostNewReviewPathWithinLimit,
|
||||
@@ -39,8 +41,10 @@ const SOURCE_ISSUES_ID = 'post-import-source-issues'
|
||||
|
||||
|
||||
const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
const locale = useLocale ()
|
||||
const { message: uiMessage, label } = useUITranslation ('imports')
|
||||
const editable = canEditContent (user)
|
||||
const navigate = useNavigate ()
|
||||
const navigate = useLocaleNavigate ()
|
||||
const [source, setSource] = useState ('')
|
||||
const [loading, setLoading] = useState (false)
|
||||
const [sourceIssues, setSourceIssues] = useState<ReturnType<typeof validateImportSource>> ([])
|
||||
@@ -51,7 +55,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
const lineCount = countImportSourceLines (source)
|
||||
const sourceUrls = extractImportSourceUrls (source)
|
||||
const withinPathLimit = isPostNewReviewPathWithinLimit (sourceUrls)
|
||||
const messages = sourceError != null ? [sourceError] : []
|
||||
const messages = sourceError != null ? [label ('source.urlRequired')] : []
|
||||
const sourceDescribedBy =
|
||||
[sourceError != null ? SOURCE_ERROR_ID : null,
|
||||
sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null]
|
||||
@@ -60,7 +64,8 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
|
||||
useEffect (() => {
|
||||
const draft = loadPostImportSourceDraft (message =>
|
||||
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
|
||||
toast ({ title: label ('source.restoreFailed'),
|
||||
description: renderPostImportMessage (message, locale) }))
|
||||
|
||||
if (!(editedRef.current))
|
||||
{
|
||||
@@ -69,7 +74,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
? draft.source
|
||||
: current)
|
||||
}
|
||||
}, [])
|
||||
}, [label, locale])
|
||||
|
||||
useEffect (() => {
|
||||
if (saveTimer.current != null)
|
||||
@@ -77,20 +82,21 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
|
||||
saveTimer.current = window.setTimeout (() => {
|
||||
savePostImportSourceDraft (source, message =>
|
||||
toast ({ title: '入力内容を保存できませんでした', description: message }))
|
||||
toast ({ title: label ('source.saveFailed'),
|
||||
description: renderPostImportMessage (message, locale) }))
|
||||
}, 300)
|
||||
|
||||
return () => {
|
||||
if (saveTimer.current != null)
|
||||
window.clearTimeout (saveTimer.current)
|
||||
}
|
||||
}, [source])
|
||||
}, [source, label, locale])
|
||||
|
||||
const preview = async () => {
|
||||
if (lineCount === 0)
|
||||
{
|
||||
setSourceIssues ([])
|
||||
setSourceError ('URL を入力してください.')
|
||||
setSourceError (uiMessage ('source.urlRequired').text)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -122,13 +128,11 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`広場に投稿を追加 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
<PageHead title={uiMessage ('source.title')}/>
|
||||
|
||||
<Form className="max-w-4xl">
|
||||
<PageTitle>広場に投稿を追加</PageTitle>
|
||||
<FormField label="URL リスト(1 行 1 URL)">
|
||||
<PageTitle>{label ('source.title')}</PageTitle>
|
||||
<FormField label={label ('source.urls')}>
|
||||
{() => (
|
||||
<TextArea
|
||||
value={source}
|
||||
@@ -139,8 +143,8 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
onBlur={() => {
|
||||
savePostImportSourceDraft (source, message =>
|
||||
toast ({
|
||||
title: '入力内容を保存できませんでした',
|
||||
description: message }))
|
||||
title: label ('source.saveFailed'),
|
||||
description: renderPostImportMessage (message, locale) }))
|
||||
}}
|
||||
onChange={ev => {
|
||||
editedRef.current = true
|
||||
@@ -157,21 +161,24 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
className="space-y-2 text-sm text-red-700 dark:text-red-300">
|
||||
{sourceIssues.map (issue => (
|
||||
<li key={`${ issue.sourceRow }-${ issue.message }-${ issue.url }`}>
|
||||
<div>{issue.sourceRow} 行目: {issue.message}</div>
|
||||
<div>
|
||||
{label ('source.rowPrefix', { row: issue.sourceRow })}
|
||||
{renderPostImportMessage (issue.message, locale)}
|
||||
</div>
|
||||
<div className="break-all font-mono text-xs">{issue.url}</div>
|
||||
</li>))}
|
||||
</ul>
|
||||
|
||||
<div className="relative z-10 flex items-center justify-between gap-3">
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
入力行数 {lineCount} / {MAX_ROWS}
|
||||
{label ('source.lineCount', { count: lineCount, limit: MAX_ROWS })}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="pointer-events-auto shrink-0"
|
||||
onClick={preview}
|
||||
disabled={loading || !(withinPathLimit)}>
|
||||
次へ
|
||||
{label ('source.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -43,12 +43,12 @@ describe ('PostListPage', () => {
|
||||
page: 2,
|
||||
limit: 20,
|
||||
order: 'original_created_at:desc',
|
||||
}),
|
||||
}), 'ja',
|
||||
)
|
||||
})
|
||||
expect (await screen.findByRole ('link', { name: '投稿5' })).toHaveAttribute (
|
||||
'href',
|
||||
'/posts/5',
|
||||
'/ja/posts/5',
|
||||
)
|
||||
expect (screen.getByText ('広場')).toBeInTheDocument ()
|
||||
})
|
||||
@@ -75,7 +75,7 @@ describe ('PostListPage', () => {
|
||||
expect (await screen.findByText ('Wiki body')).toBeInTheDocument ()
|
||||
expect (screen.getByRole ('link', { name: 'Wiki を見る' })).toHaveAttribute (
|
||||
'href',
|
||||
'/wiki/%E8%99%B9%E5%A4%8F',
|
||||
'/ja/wiki/%E8%99%B9%E5%A4%8F',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -92,7 +92,7 @@ describe ('PostListPage', () => {
|
||||
expect.objectContaining ({
|
||||
limit: 50,
|
||||
order: 'original_created_at:desc',
|
||||
}),
|
||||
}), 'ja',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -106,7 +106,7 @@ describe ('PostListPage', () => {
|
||||
|
||||
await waitFor (() => {
|
||||
expect (postsApi.fetchPosts).toHaveBeenLastCalledWith (
|
||||
expect.objectContaining ({ page: 1, limit: 100 }),
|
||||
expect.objectContaining ({ page: 1, limit: 100 }), 'ja',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||
import PostList from '@/components/PostList'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import TagSidebar from '@/components/TagSidebar'
|
||||
@@ -10,8 +10,9 @@ import WikiBody from '@/components/WikiBody'
|
||||
import Pagination from '@/components/common/Pagination'
|
||||
import TabGroup, { Tab } from '@/components/common/TabGroup'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import PageHead from '@/components/common/PageHead'
|
||||
import { fetchPosts } from '@/lib/posts'
|
||||
import { localeLanguage } from '@/lib/locales'
|
||||
import { postsKeys } from '@/lib/queryKeys'
|
||||
import {
|
||||
DEFAULT_POST_LIST_LIMIT,
|
||||
@@ -21,6 +22,8 @@ import {
|
||||
} from '@/lib/settings'
|
||||
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
||||
import { fetchWikiPageByTitle } from '@/lib/wiki'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import { useHydrated } from '@/lib/useHydrated'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
@@ -33,12 +36,15 @@ const parseLimit = (value: string | null): 20 | 50 | 100 | null => {
|
||||
|
||||
|
||||
const PostListPage: FC = () => {
|
||||
const locale = useLocale ()
|
||||
const hydrated = useHydrated ()
|
||||
const { message, label, siteTitle } = useUITranslation ('posts')
|
||||
const containerRef = useRef<HTMLDivElement | null> (null)
|
||||
|
||||
const [wikiPage, setWikiPage] = useState<WikiPage | null> (null)
|
||||
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
const navigate = useLocaleNavigate ()
|
||||
const query = new URLSearchParams (location.search)
|
||||
const tagsQuery = query.get ('tags') ?? ''
|
||||
const anyFlg = query.get ('match') === 'any'
|
||||
@@ -48,26 +54,29 @@ const PostListPage: FC = () => {
|
||||
const page = Number (query.get ('page') ?? 1)
|
||||
const limit = (
|
||||
parseLimit (query.get ('limit'))
|
||||
?? getClientListLimit ('postList')
|
||||
?? (hydrated ? getClientListLimit ('postList') : null)
|
||||
?? DEFAULT_POST_LIST_LIMIT
|
||||
)
|
||||
const order = 'original_created_at:desc' as const
|
||||
|
||||
const keys = {
|
||||
tags: tagsKey, match, page, limit,
|
||||
...(query.get ('tag_refs') && { tagReferences: query.get ('tag_refs')! }),
|
||||
url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '',
|
||||
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
|
||||
order } as const
|
||||
const { data, isLoading: loading } = useQuery ({
|
||||
queryKey: postsKeys.index (keys),
|
||||
queryFn: () => fetchPosts (keys) })
|
||||
queryKey: postsKeys.index (keys, locale),
|
||||
queryFn: () => fetchPosts (keys, locale) })
|
||||
const posts = data?.posts ?? []
|
||||
const cursor = ''
|
||||
const totalPages = data ? Math.ceil (data.count / limit) : 0
|
||||
|
||||
useEffect (() => {
|
||||
if (!hydrated)
|
||||
return
|
||||
setClientListSettings ('postList', { limit })
|
||||
}, [limit])
|
||||
}, [hydrated, limit])
|
||||
|
||||
const updatePage = (nextPage: number) => {
|
||||
const params = new URLSearchParams (location.search)
|
||||
@@ -111,27 +120,25 @@ const PostListPage: FC = () => {
|
||||
try
|
||||
{
|
||||
const tagName = tags[0]
|
||||
setWikiPage (await fetchWikiPageByTitle (tagName, { }))
|
||||
setWikiPage (await fetchWikiPageByTitle (tagName, { }, locale))
|
||||
}
|
||||
catch
|
||||
{
|
||||
;
|
||||
}
|
||||
}) ()
|
||||
}, [location.search, tags])
|
||||
}, [location.search, tags, locale])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-sidebar-container
|
||||
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"
|
||||
ref={containerRef}>
|
||||
<Helmet>
|
||||
<title>
|
||||
{tags.length
|
||||
? `${ tags.join (anyFlg ? ' or ' : ' and ') } | ${ SITE_TITLE }`
|
||||
: `${ SITE_TITLE }\u3000〜 ぼざろクリーチャーシリーズ綜合リンク集サイト`}
|
||||
</title>
|
||||
</Helmet>
|
||||
<PageHead
|
||||
title={tags.length
|
||||
? tags.join (message (anyFlg ? 'list.or' : 'list.and').text)
|
||||
: message ('list.tagline', { brand: siteTitle.text })}
|
||||
includeSiteName={tags.length > 0}/>
|
||||
|
||||
<TagSidebar posts={posts.slice (0, 20)} onClick={() => {
|
||||
const statesToSave = {
|
||||
@@ -145,7 +152,7 @@ const PostListPage: FC = () => {
|
||||
<TabGroup
|
||||
rightAddon={
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>件数</span>
|
||||
<span>{label ('common:countLabel')}</span>
|
||||
<select
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm
|
||||
text-foreground"
|
||||
@@ -160,22 +167,24 @@ const PostListPage: FC = () => {
|
||||
</select>
|
||||
</label>
|
||||
}>
|
||||
<Tab name="広場">
|
||||
<Tab name={label ('navigation:posts')}>
|
||||
{posts.length > 0
|
||||
? (
|
||||
<>
|
||||
<PostList posts={posts}/>
|
||||
<Pagination page={page} totalPages={totalPages}/>
|
||||
</>)
|
||||
: !(loading) && '広場には何もありませんよ.'}
|
||||
{loading && 'Loading...'}
|
||||
: !(loading) && label ('list.empty')}
|
||||
{loading && label ('common:loading')}
|
||||
</Tab>
|
||||
{tags.length === 1 && (
|
||||
<Tab name="Wiki">
|
||||
<WikiBody title={tags[0]} body={wikiPage?.body}/>
|
||||
<Tab name={label ('navigation:wiki')}>
|
||||
<WikiBody title={tags[0]} body={wikiPage?.body}
|
||||
language={wikiPage?.languageCode ?? (wikiPage?.resourceType === 'tag_description'
|
||||
? localeLanguage (locale) : 'und')}/>
|
||||
<div className="my-2">
|
||||
<PrefetchLink to={`/wiki/${ encodeURIComponent (tags[0]) }`}>
|
||||
Wiki を見る
|
||||
{label ('list.viewWiki')}
|
||||
</PrefetchLink>
|
||||
</div>
|
||||
</Tab>)}
|
||||
|
||||
@@ -43,12 +43,12 @@ describe ('PostSearchPage', () => {
|
||||
match: 'any',
|
||||
page: 2,
|
||||
limit: 20,
|
||||
}),
|
||||
}), 'ja',
|
||||
)
|
||||
})
|
||||
expect ((await screen.findAllByRole ('link', { name: '検索対象' }))[0]).toHaveAttribute (
|
||||
'href',
|
||||
'/posts/4',
|
||||
'/ja/posts/4',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -72,7 +72,7 @@ describe ('PostSearchPage', () => {
|
||||
tags: 'tag',
|
||||
match: 'any',
|
||||
page: 1,
|
||||
}),
|
||||
}), 'ja',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -98,7 +98,7 @@ describe ('PostSearchPage', () => {
|
||||
|
||||
await waitFor (() => {
|
||||
expect (postsApi.fetchPosts).toHaveBeenCalledWith (
|
||||
expect.objectContaining ({ limit: 50, order: 'created_at:desc' }),
|
||||
expect.objectContaining ({ limit: 50, order: 'created_at:desc' }), 'ja',
|
||||
)
|
||||
})
|
||||
expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}'))
|
||||
@@ -117,7 +117,7 @@ describe ('PostSearchPage', () => {
|
||||
|
||||
await waitFor (() => {
|
||||
expect (postsApi.fetchPosts).toHaveBeenLastCalledWith (
|
||||
expect.objectContaining ({ page: 1, limit: 100 }),
|
||||
expect.objectContaining ({ page: 1, limit: 100 }), 'ja',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import SortHeader from '@/components/SortHeader'
|
||||
import TagLink from '@/components/TagLink'
|
||||
import DateTimeField from '@/components/common/DateTimeField'
|
||||
@@ -13,7 +14,7 @@ import PageTitle from '@/components/common/PageTitle'
|
||||
import Pagination from '@/components/common/Pagination'
|
||||
import TagInput from '@/components/common/TagInput'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import PageHead from '@/components/common/PageHead'
|
||||
import { clientAnimationTransition,
|
||||
clientScrollBehaviour } from '@/lib/clientAnimation'
|
||||
import { fetchPosts } from '@/lib/posts'
|
||||
@@ -28,7 +29,9 @@ import {
|
||||
} from '@/lib/settings'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
||||
import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils'
|
||||
import { localisedDateString, inputClass, originalCreatedAtString } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import { useHydrated } from '@/lib/useHydrated'
|
||||
|
||||
import type { FC, FormEvent } from 'react'
|
||||
|
||||
@@ -70,6 +73,9 @@ const parseOrder = (value: string | null): FetchPostsOrder | null => {
|
||||
|
||||
|
||||
const PostSearchPage: FC = () => {
|
||||
const locale = useLocale ()
|
||||
const hydrated = useHydrated ()
|
||||
const { message, label } = useUITranslation ('posts')
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||
const thumbnailTransition = clientAnimationTransition (
|
||||
@@ -79,7 +85,7 @@ const PostSearchPage: FC = () => {
|
||||
)
|
||||
const location = useLocation ()
|
||||
|
||||
const navigate = useNavigate ()
|
||||
const navigate = useLocaleNavigate ()
|
||||
|
||||
const query = useMemo (() => new URLSearchParams (location.search),
|
||||
[location.search])
|
||||
@@ -87,7 +93,7 @@ const PostSearchPage: FC = () => {
|
||||
const page = Number (query.get ('page') ?? 1)
|
||||
const limit = (
|
||||
parseLimit (query.get ('limit'))
|
||||
?? getClientListLimit ('postSearch')
|
||||
?? (hydrated ? getClientListLimit ('postSearch') : null)
|
||||
?? DEFAULT_POST_LIST_LIMIT
|
||||
)
|
||||
|
||||
@@ -103,7 +109,7 @@ const PostSearchPage: FC = () => {
|
||||
const qUpdatedTo = query.get ('updated_to') ?? ''
|
||||
const order = (
|
||||
parseOrder (query.get ('order'))
|
||||
?? getClientListOrder<FetchPostsOrder> ('postSearch')
|
||||
?? (hydrated ? getClientListOrder<FetchPostsOrder> ('postSearch') : null)
|
||||
?? DEFAULT_POST_LIST_ORDER
|
||||
)
|
||||
|
||||
@@ -120,6 +126,7 @@ const PostSearchPage: FC = () => {
|
||||
|
||||
const keys: FetchPostsParams = {
|
||||
tags: qTags, match: qMatch, page, limit,
|
||||
...(query.get ('tag_refs') && { tagReferences: query.get ('tag_refs')! }),
|
||||
url: qURL,
|
||||
title: qTitle,
|
||||
originalCreatedFrom: qOriginalCreatedFrom,
|
||||
@@ -130,14 +137,16 @@ const PostSearchPage: FC = () => {
|
||||
updatedTo: qUpdatedTo,
|
||||
order }
|
||||
const { data, isLoading: loading } = useQuery ({
|
||||
queryKey: postsKeys.index (keys),
|
||||
queryFn: () => fetchPosts (keys) })
|
||||
queryKey: postsKeys.index (keys, locale),
|
||||
queryFn: () => fetchPosts (keys, locale) })
|
||||
const results = data?.posts ?? []
|
||||
const totalPages = data ? Math.ceil (data.count / limit) : 0
|
||||
|
||||
useEffect (() => {
|
||||
if (!hydrated)
|
||||
return
|
||||
setClientListSettings ('postSearch', { limit, order })
|
||||
}, [limit, order])
|
||||
}, [hydrated, limit, order])
|
||||
|
||||
useEffect (() => {
|
||||
setURL (qURL ?? '')
|
||||
@@ -220,15 +229,13 @@ const PostSearchPage: FC = () => {
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>広場検索 | {SITE_TITLE}</title>
|
||||
</Helmet>
|
||||
<PageHead title={message ('search.title')}/>
|
||||
|
||||
<div className="max-w-xl">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<PageTitle>広場検索</PageTitle>
|
||||
<PageTitle>{label ('search.title')}</PageTitle>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>件数</span>
|
||||
<span>{label ('common:countLabel')}</span>
|
||||
<select
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm
|
||||
text-foreground"
|
||||
@@ -246,7 +253,7 @@ const PostSearchPage: FC = () => {
|
||||
|
||||
<form onSubmit={handleSearch} className="space-y-2">
|
||||
{/* タイトル */}
|
||||
<FormField label="タイトル">
|
||||
<FormField label={label ('shared:labels.title')}>
|
||||
{({ invalid }) => (
|
||||
<input
|
||||
type="text"
|
||||
@@ -257,7 +264,7 @@ const PostSearchPage: FC = () => {
|
||||
</FormField>
|
||||
|
||||
{/* URL */}
|
||||
<FormField label="URL">
|
||||
<FormField label={label ('shared:url')}>
|
||||
{({ invalid }) => (
|
||||
<input
|
||||
type="text"
|
||||
@@ -267,7 +274,7 @@ const PostSearchPage: FC = () => {
|
||||
</FormField>
|
||||
|
||||
{/* タグ */}
|
||||
<FormField label="タグ">
|
||||
<FormField label={label ('navigation:tags')}>
|
||||
{() => (
|
||||
<TagInput
|
||||
includeNico={true}
|
||||
@@ -276,14 +283,14 @@ const PostSearchPage: FC = () => {
|
||||
</FormField>
|
||||
<div>
|
||||
<fieldset className="w-full my-2">
|
||||
<label>検索区分:</label>
|
||||
<label>{label ('search.matchType')}</label>
|
||||
<label className="mx-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="match-type"
|
||||
checked={matchType === 'all'}
|
||||
onChange={() => setMatchType ('all')}/>
|
||||
AND
|
||||
{label ('search.all')}
|
||||
</label>
|
||||
<label className="mx-2">
|
||||
<input
|
||||
@@ -291,13 +298,13 @@ const PostSearchPage: FC = () => {
|
||||
name="match-type"
|
||||
checked={matchType === 'any'}
|
||||
onChange={() => setMatchType ('any')}/>
|
||||
OR
|
||||
{label ('search.any')}
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{/* オリジナルの投稿日時 */}
|
||||
<FormField label="オリジナルの投稿日時">
|
||||
<FormField label={label ('history:originalPostedAt')}>
|
||||
{() => (
|
||||
<>
|
||||
<DateTimeField
|
||||
@@ -311,7 +318,7 @@ const PostSearchPage: FC = () => {
|
||||
</FormField>
|
||||
|
||||
{/* 投稿日時 */}
|
||||
<FormField label="投稿日時">
|
||||
<FormField label={label ('search.createdAt')}>
|
||||
{() => (
|
||||
<>
|
||||
<DateTimeField
|
||||
@@ -325,7 +332,7 @@ const PostSearchPage: FC = () => {
|
||||
</FormField>
|
||||
|
||||
{/* 更新日時 */}
|
||||
<FormField label="更新日時">
|
||||
<FormField label={label ('tags:updatedAt')}>
|
||||
{() => (
|
||||
<>
|
||||
<DateTimeField
|
||||
@@ -343,13 +350,13 @@ const PostSearchPage: FC = () => {
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded">
|
||||
検索
|
||||
{label ('common:search')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{loading ? 'Loading...' : (results.length > 0 ? (
|
||||
{loading ? label ('common:loading') : (results.length > 0 ? (
|
||||
<div className="mt-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1200px] table-fixed border-collapse">
|
||||
@@ -365,40 +372,40 @@ const PostSearchPage: FC = () => {
|
||||
|
||||
<thead className="border-b-2 border-black dark:border-white">
|
||||
<tr>
|
||||
<th className="p-2 text-left whitespace-nowrap">投稿</th>
|
||||
<th className="p-2 text-left whitespace-nowrap">{label ('history:post')}</th>
|
||||
<th className="p-2 text-left whitespace-nowrap">
|
||||
<SortHeader<FetchPostsOrderField>
|
||||
by="title"
|
||||
label="タイトル"
|
||||
label={label ('shared:labels.title')}
|
||||
currentOrder={order}
|
||||
defaultDirection={defaultDirection}/>
|
||||
</th>
|
||||
<th className="p-2 text-left whitespace-nowrap">
|
||||
<SortHeader<FetchPostsOrderField>
|
||||
by="url"
|
||||
label="URL"
|
||||
label={label ('shared:url')}
|
||||
currentOrder={order}
|
||||
defaultDirection={defaultDirection}/>
|
||||
</th>
|
||||
<th className="p-2 text-left whitespace-nowrap">タグ</th>
|
||||
<th className="p-2 text-left whitespace-nowrap">{label ('navigation:tags')}</th>
|
||||
<th className="p-2 text-left whitespace-nowrap">
|
||||
<SortHeader<FetchPostsOrderField>
|
||||
by="original_created_at"
|
||||
label="オリジナルの投稿日時"
|
||||
label={label ('history:originalPostedAt')}
|
||||
currentOrder={order}
|
||||
defaultDirection={defaultDirection}/>
|
||||
</th>
|
||||
<th className="p-2 text-left whitespace-nowrap">
|
||||
<SortHeader<FetchPostsOrderField>
|
||||
by="created_at"
|
||||
label="投稿日時"
|
||||
label={label ('search.createdAt')}
|
||||
currentOrder={order}
|
||||
defaultDirection={defaultDirection}/>
|
||||
</th>
|
||||
<th className="p-2 text-left whitespace-nowrap">
|
||||
<SortHeader<FetchPostsOrderField>
|
||||
by="updated_at"
|
||||
label="更新日時"
|
||||
label={label ('tags:updatedAt')}
|
||||
currentOrder={order}
|
||||
defaultDirection={defaultDirection}/>
|
||||
</th>
|
||||
@@ -443,18 +450,28 @@ const PostSearchPage: FC = () => {
|
||||
</span>))}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{originalCreatedAtString (row.originalCreatedFrom,
|
||||
row.originalCreatedBefore)}
|
||||
<LocalisedText language={locale}>
|
||||
{originalCreatedAtString (row.originalCreatedFrom,
|
||||
row.originalCreatedBefore, locale)}
|
||||
</LocalisedText>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<LocalisedText language={locale}>
|
||||
{localisedDateString (row.createdAt, locale)}
|
||||
</LocalisedText>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<LocalisedText language={locale}>
|
||||
{localisedDateString (row.updatedAt, locale)}
|
||||
</LocalisedText>
|
||||
</td>
|
||||
<td className="p-2">{dateString (row.createdAt)}</td>
|
||||
<td className="p-2">{dateString (row.updatedAt)}</td>
|
||||
</tr>))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination page={page} totalPages={totalPages}/>
|
||||
</div>) : '結果ないよ(笑)')}
|
||||
</div>) : label ('tags:empty'))}
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする