479 行
15 KiB
TypeScript
479 行
15 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
|
import { motion } from 'framer-motion'
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
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'
|
|
import FormField from '@/components/common/FormField'
|
|
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 PageHead from '@/components/common/PageHead'
|
|
import { clientAnimationTransition,
|
|
clientScrollBehaviour } from '@/lib/clientAnimation'
|
|
import { fetchPosts } from '@/lib/posts'
|
|
import { postsKeys } from '@/lib/queryKeys'
|
|
import {
|
|
DEFAULT_POST_LIST_LIMIT,
|
|
DEFAULT_POST_LIST_ORDER,
|
|
getClientListLimit,
|
|
getClientListOrder,
|
|
LIST_LIMIT_OPTIONS,
|
|
setClientListSettings,
|
|
} from '@/lib/settings'
|
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
|
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
|
import { localisedDateString, inputClass, originalCreatedAtString } from '@/lib/utils'
|
|
import { useUITranslation } from '@/lib/useUITranslation'
|
|
import { useHydrated } from '@/lib/useHydrated'
|
|
|
|
import type { FC, FormEvent } from 'react'
|
|
|
|
import type { FetchPostsOrder,
|
|
FetchPostsOrderField,
|
|
FetchPostsParams } from '@/types'
|
|
|
|
|
|
const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
|
|
const t = v?.trim ()
|
|
if (t)
|
|
qs.set (k, t)
|
|
}
|
|
|
|
const parseLimit = (value: string | null): 20 | 50 | 100 | null => {
|
|
const n = Number (value)
|
|
return n === 20 || n === 50 || n === 100 ? n : null
|
|
}
|
|
|
|
const parseOrder = (value: string | null): FetchPostsOrder | null => {
|
|
switch (value)
|
|
{
|
|
case 'title:asc':
|
|
case 'title:desc':
|
|
case 'url:asc':
|
|
case 'url:desc':
|
|
case 'original_created_at:asc':
|
|
case 'original_created_at:desc':
|
|
case 'created_at:asc':
|
|
case 'created_at:desc':
|
|
case 'updated_at:asc':
|
|
case 'updated_at:desc':
|
|
return value
|
|
|
|
default:
|
|
return 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 (
|
|
animationMode,
|
|
{ normal: { type: 'spring', stiffness: 500, damping: 40, mass: .5 },
|
|
reduced: { duration: .08, ease: 'linear' as const } },
|
|
)
|
|
const location = useLocation ()
|
|
|
|
const navigate = useLocaleNavigate ()
|
|
|
|
const query = useMemo (() => new URLSearchParams (location.search),
|
|
[location.search])
|
|
|
|
const page = Number (query.get ('page') ?? 1)
|
|
const limit = (
|
|
parseLimit (query.get ('limit'))
|
|
?? (hydrated ? getClientListLimit ('postSearch') : null)
|
|
?? DEFAULT_POST_LIST_LIMIT
|
|
)
|
|
|
|
const qURL = query.get ('url') ?? ''
|
|
const qTitle = query.get ('title') ?? ''
|
|
const qTags = query.get ('tags') ?? ''
|
|
const qMatch: 'all' | 'any' = query.get ('match') === 'any' ? 'any' : 'all'
|
|
const qOriginalCreatedFrom = query.get ('original_created_from') ?? ''
|
|
const qOriginalCreatedTo = query.get ('original_created_to') ?? ''
|
|
const qCreatedFrom = query.get ('created_from') ?? ''
|
|
const qCreatedTo = query.get ('created_to') ?? ''
|
|
const qUpdatedFrom = query.get ('updated_from') ?? ''
|
|
const qUpdatedTo = query.get ('updated_to') ?? ''
|
|
const order = (
|
|
parseOrder (query.get ('order'))
|
|
?? (hydrated ? getClientListOrder<FetchPostsOrder> ('postSearch') : null)
|
|
?? DEFAULT_POST_LIST_ORDER
|
|
)
|
|
|
|
const [createdFrom, setCreatedFrom] = useState<string | null> (null)
|
|
const [createdTo, setCreatedTo] = useState<string | null> (null)
|
|
const [matchType, setMatchType] = useState<'all' | 'any'> ('all')
|
|
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
|
|
const [originalCreatedTo, setOriginalCreatedTo] = useState<string | null> (null)
|
|
const [tagsStr, setTagsStr] = useState ('')
|
|
const [title, setTitle] = useState ('')
|
|
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
|
|
const [updatedTo, setUpdatedTo] = useState<string | null> (null)
|
|
const [url, setURL] = useState ('')
|
|
|
|
const keys: FetchPostsParams = {
|
|
tags: qTags, match: qMatch, page, limit,
|
|
...(query.get ('tag_refs') && { tagReferences: query.get ('tag_refs')! }),
|
|
url: qURL,
|
|
title: qTitle,
|
|
originalCreatedFrom: qOriginalCreatedFrom,
|
|
originalCreatedTo: qOriginalCreatedTo,
|
|
createdFrom: qCreatedFrom,
|
|
createdTo: qCreatedTo,
|
|
updatedFrom: qUpdatedFrom,
|
|
updatedTo: qUpdatedTo,
|
|
order }
|
|
const { data, isLoading: loading } = useQuery ({
|
|
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 })
|
|
}, [hydrated, limit, order])
|
|
|
|
useEffect (() => {
|
|
setURL (qURL ?? '')
|
|
setTitle (qTitle ?? '')
|
|
setTagsStr (qTags ?? '')
|
|
setMatchType (qMatch ?? 'all')
|
|
setOriginalCreatedFrom (qOriginalCreatedFrom)
|
|
setOriginalCreatedTo (qOriginalCreatedTo)
|
|
setCreatedFrom (qCreatedFrom)
|
|
setCreatedTo (qCreatedTo)
|
|
setUpdatedFrom (qUpdatedFrom)
|
|
setUpdatedTo (qUpdatedTo)
|
|
|
|
document.querySelector ('table')?.scrollIntoView ({
|
|
behavior: clientScrollBehaviour (animationMode) })
|
|
}, [animationMode, location.search, qCreatedFrom, qCreatedTo, qMatch,
|
|
qOriginalCreatedFrom, qOriginalCreatedTo, qTags, qTitle, qUpdatedFrom,
|
|
qUpdatedTo, qURL])
|
|
|
|
const search = async () => {
|
|
const qs = new URLSearchParams ()
|
|
setIf (qs, 'tags', tagsStr)
|
|
setIf (qs, 'url', url)
|
|
setIf (qs, 'title', title)
|
|
setIf (qs, 'original_created_from', originalCreatedFrom)
|
|
setIf (qs, 'original_created_to', originalCreatedTo)
|
|
setIf (qs, 'created_from', createdFrom)
|
|
setIf (qs, 'created_to', createdTo)
|
|
setIf (qs, 'updated_from', updatedFrom)
|
|
setIf (qs, 'updated_to', updatedTo)
|
|
qs.set ('match', matchType)
|
|
qs.set ('page', '1')
|
|
qs.set ('order', order)
|
|
qs.set ('limit', String (limit))
|
|
navigate (`${ location.pathname }?${ qs.toString () }`)
|
|
}
|
|
|
|
const updateListQuery = (next: { limit?: 20 | 50 | 100
|
|
order?: FetchPostsOrder }) => {
|
|
const qs = new URLSearchParams (location.search)
|
|
qs.set ('limit', String (next.limit ?? limit))
|
|
qs.set ('order', next.order ?? order)
|
|
qs.set ('page', '1')
|
|
navigate (`${ location.pathname }?${ qs.toString () }`)
|
|
}
|
|
|
|
const updatePage = (nextPage: number) => {
|
|
const qs = new URLSearchParams (location.search)
|
|
qs.set ('page', String (Math.max (1, nextPage)))
|
|
navigate (`${ location.pathname }?${ qs.toString () }`)
|
|
}
|
|
|
|
const handleSearch = (e: FormEvent) => {
|
|
e.preventDefault ()
|
|
search ()
|
|
}
|
|
|
|
useKeyboardShortcuts (useMemo (() => ({
|
|
...(page > 1
|
|
? {
|
|
'pagination.previous': () => {
|
|
updatePage (page - 1)
|
|
},
|
|
}
|
|
: { }),
|
|
...(totalPages > 0 && page < totalPages
|
|
? {
|
|
'pagination.next': () => {
|
|
updatePage (Math.min (page + 1, totalPages))
|
|
},
|
|
}
|
|
: { }),
|
|
}), [page, totalPages, location.search]))
|
|
|
|
const defaultDirection = { title: 'asc',
|
|
url: 'asc',
|
|
original_created_at: 'desc',
|
|
created_at: 'desc',
|
|
updated_at: 'desc' } as const
|
|
|
|
return (
|
|
<MainArea>
|
|
<PageHead title={message ('search.title')}/>
|
|
|
|
<div className="max-w-xl">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<PageTitle>{label ('search.title')}</PageTitle>
|
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<span>{label ('common:countLabel')}</span>
|
|
<select
|
|
className="rounded border border-border bg-background px-2 py-1 text-sm
|
|
text-foreground"
|
|
value={limit}
|
|
onChange={ev => updateListQuery ({
|
|
limit: Number (ev.target.value) as 20 | 50 | 100,
|
|
})}>
|
|
{LIST_LIMIT_OPTIONS.map (value => (
|
|
<option key={value} value={value}>
|
|
{value}
|
|
</option>))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<form onSubmit={handleSearch} className="space-y-2">
|
|
{/* タイトル */}
|
|
<FormField label={label ('shared:labels.title')}>
|
|
{({ invalid }) => (
|
|
<input
|
|
type="text"
|
|
data-shortcut-focus-search="true"
|
|
value={title}
|
|
onChange={e => setTitle (e.target.value)}
|
|
className={inputClass (invalid)}/>)}
|
|
</FormField>
|
|
|
|
{/* URL */}
|
|
<FormField label={label ('shared:url')}>
|
|
{({ invalid }) => (
|
|
<input
|
|
type="text"
|
|
value={url}
|
|
onChange={e => setURL (e.target.value)}
|
|
className={inputClass (invalid)}/>)}
|
|
</FormField>
|
|
|
|
{/* タグ */}
|
|
<FormField label={label ('navigation:tags')}>
|
|
{() => (
|
|
<TagInput
|
|
includeNico={true}
|
|
value={tagsStr}
|
|
setValue={setTagsStr}/>)}
|
|
</FormField>
|
|
<div>
|
|
<fieldset className="w-full my-2">
|
|
<label>{label ('search.matchType')}</label>
|
|
<label className="mx-2">
|
|
<input
|
|
type="radio"
|
|
name="match-type"
|
|
checked={matchType === 'all'}
|
|
onChange={() => setMatchType ('all')}/>
|
|
{label ('search.all')}
|
|
</label>
|
|
<label className="mx-2">
|
|
<input
|
|
type="radio"
|
|
name="match-type"
|
|
checked={matchType === 'any'}
|
|
onChange={() => setMatchType ('any')}/>
|
|
{label ('search.any')}
|
|
</label>
|
|
</fieldset>
|
|
</div>
|
|
|
|
{/* オリジナルの投稿日時 */}
|
|
<FormField label={label ('history:originalPostedAt')}>
|
|
{() => (
|
|
<>
|
|
<DateTimeField
|
|
value={originalCreatedFrom ?? undefined}
|
|
onChange={setOriginalCreatedFrom}/>
|
|
<span className="mx-1">〜</span>
|
|
<DateTimeField
|
|
value={originalCreatedTo ?? undefined}
|
|
onChange={setOriginalCreatedTo}/>
|
|
</>)}
|
|
</FormField>
|
|
|
|
{/* 投稿日時 */}
|
|
<FormField label={label ('search.createdAt')}>
|
|
{() => (
|
|
<>
|
|
<DateTimeField
|
|
value={createdFrom ?? undefined}
|
|
onChange={setCreatedFrom}/>
|
|
<span className="mx-1">〜</span>
|
|
<DateTimeField
|
|
value={createdTo ?? undefined}
|
|
onChange={setCreatedTo}/>
|
|
</>)}
|
|
</FormField>
|
|
|
|
{/* 更新日時 */}
|
|
<FormField label={label ('tags:updatedAt')}>
|
|
{() => (
|
|
<>
|
|
<DateTimeField
|
|
value={updatedFrom ?? undefined}
|
|
onChange={setUpdatedFrom}/>
|
|
<span className="mx-1">〜</span>
|
|
<DateTimeField
|
|
value={updatedTo ?? undefined}
|
|
onChange={setUpdatedTo}/>
|
|
</>)}
|
|
</FormField>
|
|
|
|
{/* 検索 */}
|
|
<div className="py-3">
|
|
<button
|
|
type="submit"
|
|
className="bg-blue-500 text-white px-4 py-2 rounded">
|
|
{label ('common:search')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
{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">
|
|
<colgroup>
|
|
<col className="w-14"/>
|
|
<col className="w-72"/>
|
|
<col className="w-80"/>
|
|
<col className="w-[24rem]"/>
|
|
<col className="w-60"/>
|
|
<col className="w-44"/>
|
|
<col className="w-44"/>
|
|
</colgroup>
|
|
|
|
<thead className="border-b-2 border-black dark:border-white">
|
|
<tr>
|
|
<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 ('shared:labels.title')}
|
|
currentOrder={order}
|
|
defaultDirection={defaultDirection}/>
|
|
</th>
|
|
<th className="p-2 text-left whitespace-nowrap">
|
|
<SortHeader<FetchPostsOrderField>
|
|
by="url"
|
|
label={label ('shared:url')}
|
|
currentOrder={order}
|
|
defaultDirection={defaultDirection}/>
|
|
</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 ('history:originalPostedAt')}
|
|
currentOrder={order}
|
|
defaultDirection={defaultDirection}/>
|
|
</th>
|
|
<th className="p-2 text-left whitespace-nowrap">
|
|
<SortHeader<FetchPostsOrderField>
|
|
by="created_at"
|
|
label={label ('search.createdAt')}
|
|
currentOrder={order}
|
|
defaultDirection={defaultDirection}/>
|
|
</th>
|
|
<th className="p-2 text-left whitespace-nowrap">
|
|
<SortHeader<FetchPostsOrderField>
|
|
by="updated_at"
|
|
label={label ('tags:updatedAt')}
|
|
currentOrder={order}
|
|
defaultDirection={defaultDirection}/>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{results.map (row => (
|
|
<tr
|
|
key={row.id}
|
|
className="even:bg-gray-100 dark:even:bg-gray-700">
|
|
<td className="p-2">
|
|
<PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}>
|
|
<motion.div
|
|
layoutId={animationMode === 'off'
|
|
? undefined
|
|
: `page-${ row.id }`}
|
|
transition={thumbnailTransition}>
|
|
<img src={row.thumbnail || row.thumbnailBase || undefined}
|
|
alt={row.title || row.url}
|
|
title={row.title || row.url || undefined}
|
|
className="w-8"/>
|
|
</motion.div>
|
|
</PrefetchLink>
|
|
</td>
|
|
<td className="p-2 truncate">
|
|
<PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}>
|
|
{row.title}
|
|
</PrefetchLink>
|
|
</td>
|
|
<td className="p-2 truncate">
|
|
<a href={row.url}
|
|
title={row.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer nofollow">
|
|
{row.url}
|
|
</a>
|
|
</td>
|
|
<td className="p-2">
|
|
{row.tags.map (t => (
|
|
<span key={t.id} className="mr-2">
|
|
<TagLink tag={t} withWiki={false} withCount={false}/>
|
|
</span>))}
|
|
</td>
|
|
<td className="p-2">
|
|
<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>
|
|
</tr>))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<Pagination page={page} totalPages={totalPages}/>
|
|
</div>) : label ('tags:empty'))}
|
|
</MainArea>)
|
|
}
|
|
|
|
export default PostSearchPage
|