|
- 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
|