ファイル
btrc-hub/frontend/src/pages/posts/PostSearchPage.tsx
T
2026-06-05 01:59:46 +09:00

351 行
11 KiB
TypeScript
Raw Blame 履歴

このファイルには曖昧(ambiguous)なUnicode文字が含まれてゐます
このファイルには,他の文字と見間違える可能性があるUnicode文字が含まれてゐます. それが意図的なものと考えられる場合は,この警告を無視して構ゐません. それらの文字を表示するにはエスケープボタンを使用します.
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 PrefetchLink from '@/components/PrefetchLink'
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 { SITE_TITLE } from '@/config'
import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils'
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 PostSearchPage: FC = () => {
const location = useLocation ()
const navigate = useNavigate ()
const query = useMemo (() => new URLSearchParams (location.search),
[location.search])
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
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 = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder
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,
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),
queryFn: () => fetchPosts (keys) })
const results = data?.posts ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
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: 'smooth' })
}, [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)
navigate (`${ location.pathname }?${ qs.toString () }`)
}
const handleSearch = (e: FormEvent) => {
e.preventDefault ()
search ()
}
const defaultDirection = { title: 'asc',
url: 'asc',
original_created_at: 'desc',
created_at: 'desc',
updated_at: 'desc' } as const
return (
<MainArea>
<Helmet>
<title> | {SITE_TITLE}</title>
</Helmet>
<div className="max-w-xl">
<PageTitle></PageTitle>
<form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */}
<FormField label="タイトル">
{({ invalid }) => (
<input
type="text"
value={title}
onChange={e => setTitle (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
{/* URL */}
<FormField label="URL">
{({ invalid }) => (
<input
type="text"
value={url}
onChange={e => setURL (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
{/* タグ */}
<FormField label="タグ">
{() => (
<TagInput
value={tagsStr}
setValue={setTagsStr}/>)}
</FormField>
<div>
<fieldset className="w-full my-2">
<label></label>
<label className="mx-2">
<input
type="radio"
name="match-type"
checked={matchType === 'all'}
onChange={() => setMatchType ('all')}/>
AND
</label>
<label className="mx-2">
<input
type="radio"
name="match-type"
checked={matchType === 'any'}
onChange={() => setMatchType ('any')}/>
OR
</label>
</fieldset>
</div>
{/* オリジナルの投稿日時 */}
<FormField label="オリジナルの投稿日時">
{() => (
<>
<DateTimeField
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}/>
<span className="mx-1"></span>
<DateTimeField
value={originalCreatedTo ?? undefined}
onChange={setOriginalCreatedTo}/>
</>)}
</FormField>
{/* 投稿日時 */}
<FormField label="投稿日時">
{() => (
<>
<DateTimeField
value={createdFrom ?? undefined}
onChange={setCreatedFrom}/>
<span className="mx-1"></span>
<DateTimeField
value={createdTo ?? undefined}
onChange={setCreatedTo}/>
</>)}
</FormField>
{/* 更新日時 */}
<FormField label="更新日時">
{() => (
<>
<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">
</button>
</div>
</form>
</div>
{loading ? '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">稿</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchPostsOrderField>
by="title"
label="タイトル"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchPostsOrderField>
by="url"
label="URL"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap"></th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchPostsOrderField>
by="original_created_at"
label="オリジナルの投稿日時"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchPostsOrderField>
by="created_at"
label="投稿日時"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchPostsOrderField>
by="updated_at"
label="更新日時"
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={`page-${ row.id }`}
transition={{ type: 'spring',
stiffness: 500,
damping: 40,
mass: .5 }}>
<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">
{originalCreatedAtString (row.originalCreatedFrom,
row.originalCreatedBefore)}
</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>) : '結果ないよ(笑)')}
</MainArea>)
}
export default PostSearchPage