9e3cbd2469
#206 エラー修正 #206 updated_at の並び順修正 Merge remote-tracking branch 'origin/main' into feature/206 Merge branch 'main' into feature/206 Merge branch 'main' into feature/206 Merge branch 'main' into feature/206 #206 #206 #206 #206 #206 #206 タグ補完追加 #206 #206 #206 #206 #206 Merge remote-tracking branch 'origin/main' into feature/206 #206 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: #274
411 行
12 KiB
TypeScript
411 行
12 KiB
TypeScript
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 TagLink from '@/components/TagLink'
|
||
import TagSearchBox from '@/components/TagSearchBox'
|
||
import DateTimeField from '@/components/common/DateTimeField'
|
||
import Label from '@/components/common/Label'
|
||
import PageTitle from '@/components/common/PageTitle'
|
||
import Pagination from '@/components/common/Pagination'
|
||
import MainArea from '@/components/layout/MainArea'
|
||
import { SITE_TITLE } from '@/config'
|
||
import { apiGet } from '@/lib/api'
|
||
import { fetchPosts } from '@/lib/posts'
|
||
import { postsKeys } from '@/lib/queryKeys'
|
||
import { dateString, originalCreatedAtString } from '@/lib/utils'
|
||
|
||
import type { FC, ChangeEvent, FormEvent, KeyboardEvent } from 'react'
|
||
|
||
import type { FetchPostsOrder,
|
||
FetchPostsOrderField,
|
||
FetchPostsParams,
|
||
Tag } from '@/types'
|
||
|
||
|
||
const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
|
||
const t = v?.trim ()
|
||
if (t)
|
||
qs.set (k, t)
|
||
}
|
||
|
||
|
||
export default (() => {
|
||
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 [activeIndex, setActiveIndex] = useState (-1)
|
||
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 [suggestions, setSuggestions] = useState<Tag[]> ([])
|
||
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
||
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])
|
||
|
||
const SortHeader = ({ by, label }: { by: FetchPostsOrderField; label: string }) => {
|
||
const [fld, dir] = order.split (':')
|
||
|
||
const qs = new URLSearchParams (location.search)
|
||
const nextDir =
|
||
(by === fld)
|
||
? (dir === 'asc' ? 'desc' : 'asc')
|
||
: (['title', 'url'].includes (by) ? 'asc' : 'desc')
|
||
qs.set ('order', `${ by }:${ nextDir }`)
|
||
qs.set ('page', '1')
|
||
|
||
return (
|
||
<PrefetchLink
|
||
className="text-inherit visited:text-inherit hover:text-inherit"
|
||
to={`${ location.pathname }?${ qs.toString () }`}>
|
||
<span className="font-bold">
|
||
{label}
|
||
{by === fld && (dir === 'asc' ? ' ▲' : ' ▼')}
|
||
</span>
|
||
</PrefetchLink>)
|
||
}
|
||
|
||
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
||
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
|
||
setTagsStr (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)
|
||
}
|
||
|
||
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
||
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
|
||
switch (ev.key)
|
||
{
|
||
case 'ArrowDown':
|
||
ev.preventDefault ()
|
||
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
|
||
setSuggestionsVsbl (true)
|
||
break
|
||
|
||
case 'ArrowUp':
|
||
ev.preventDefault ()
|
||
setActiveIndex (i => Math.max (i - 1, -1))
|
||
setSuggestionsVsbl (true)
|
||
break
|
||
|
||
case 'Enter':
|
||
if (activeIndex < 0)
|
||
break
|
||
ev.preventDefault ()
|
||
const selected = suggestions[activeIndex]
|
||
selected && handleTagSelect (selected)
|
||
break
|
||
|
||
case 'Escape':
|
||
ev.preventDefault ()
|
||
setSuggestionsVsbl (false)
|
||
break
|
||
}
|
||
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
|
||
{
|
||
setSuggestionsVsbl (false)
|
||
}
|
||
}
|
||
|
||
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', String ('1'))
|
||
qs.set ('order', order)
|
||
navigate (`${ location.pathname }?${ qs.toString () }`)
|
||
}
|
||
|
||
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
||
const handleTagSelect = (tag: Tag) => {
|
||
const parts = tagsStr.split (' ')
|
||
parts[parts.length - 1] = tag.name
|
||
setTagsStr (parts.join (' ') + ' ')
|
||
setSuggestions ([])
|
||
setActiveIndex (-1)
|
||
}
|
||
|
||
const handleSearch = (e: FormEvent) => {
|
||
e.preventDefault ()
|
||
search ()
|
||
}
|
||
|
||
return (
|
||
<MainArea>
|
||
<Helmet>
|
||
<title>広場検索 | {SITE_TITLE}</title>
|
||
</Helmet>
|
||
|
||
<div className="max-w-xl">
|
||
<PageTitle>広場検索</PageTitle>
|
||
|
||
<form onSubmit={handleSearch} className="space-y-2">
|
||
{/* タイトル */}
|
||
<div>
|
||
<Label>タイトル</Label>
|
||
<input
|
||
type="text"
|
||
value={title}
|
||
onChange={e => setTitle (e.target.value)}
|
||
className="w-full border p-2 rounded"/>
|
||
</div>
|
||
|
||
{/* URL */}
|
||
<div>
|
||
<Label>URL</Label>
|
||
<input
|
||
type="text"
|
||
value={url}
|
||
onChange={e => setURL (e.target.value)}
|
||
className="w-full border p-2 rounded"/>
|
||
</div>
|
||
|
||
{/* タグ */}
|
||
<div className="relative">
|
||
<Label>タグ</Label>
|
||
<input
|
||
type="text"
|
||
value={tagsStr}
|
||
onChange={whenChanged}
|
||
onFocus={() => setSuggestionsVsbl (true)}
|
||
onBlur={() => setSuggestionsVsbl (false)}
|
||
onKeyDown={handleKeyDown}
|
||
className="w-full border p-2 rounded"/>
|
||
<TagSearchBox
|
||
suggestions={
|
||
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
|
||
activeIndex={activeIndex}
|
||
onSelect={handleTagSelect}/>
|
||
<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>
|
||
|
||
{/* オリジナルの投稿日時 */}
|
||
<div>
|
||
<Label>オリジナルの投稿日時</Label>
|
||
<DateTimeField
|
||
value={originalCreatedFrom ?? undefined}
|
||
onChange={setOriginalCreatedFrom}/>
|
||
<span className="mx-1">〜</span>
|
||
<DateTimeField
|
||
value={originalCreatedTo ?? undefined}
|
||
onChange={setOriginalCreatedTo}/>
|
||
</div>
|
||
|
||
{/* 投稿日時 */}
|
||
<div>
|
||
<Label>投稿日時</Label>
|
||
<DateTimeField
|
||
value={createdFrom ?? undefined}
|
||
onChange={setCreatedFrom}/>
|
||
<span className="mx-1">〜</span>
|
||
<DateTimeField
|
||
value={createdTo ?? undefined}
|
||
onChange={setCreatedTo}/>
|
||
</div>
|
||
|
||
{/* 更新日時 */}
|
||
<div>
|
||
<Label>更新日時</Label>
|
||
<DateTimeField
|
||
value={updatedFrom ?? undefined}
|
||
onChange={setUpdatedFrom}/>
|
||
<span className="mx-1">〜</span>
|
||
<DateTimeField
|
||
value={updatedTo ?? undefined}
|
||
onChange={setUpdatedTo}/>
|
||
</div>
|
||
|
||
{/* 検索 */}
|
||
<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 by="title" label="タイトル"/>
|
||
</th>
|
||
<th className="p-2 text-left whitespace-nowrap">
|
||
<SortHeader by="url" label="URL"/>
|
||
</th>
|
||
<th className="p-2 text-left whitespace-nowrap">タグ</th>
|
||
<th className="p-2 text-left whitespace-nowrap">
|
||
<SortHeader by="original_created_at" label="オリジナルの投稿日時"/>
|
||
</th>
|
||
<th className="p-2 text-left whitespace-nowrap">
|
||
<SortHeader by="created_at" label="投稿日時"/>
|
||
</th>
|
||
<th className="p-2 text-left whitespace-nowrap">
|
||
<SortHeader by="updated_at" label="更新日時"/>
|
||
</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}>
|
||
<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}>
|
||
{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>)
|
||
}) satisfies FC
|