ぼざクリタグ広場 https://hub.nizika.monster
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

343 lines
10 KiB

  1. import { useQuery } from '@tanstack/react-query'
  2. import { motion } from 'framer-motion'
  3. import { useEffect, useMemo, useState } from 'react'
  4. import { Helmet } from 'react-helmet-async'
  5. import { useLocation, useNavigate } from 'react-router-dom'
  6. import PrefetchLink from '@/components/PrefetchLink'
  7. import SortHeader from '@/components/SortHeader'
  8. import TagLink from '@/components/TagLink'
  9. import DateTimeField from '@/components/common/DateTimeField'
  10. import Label from '@/components/common/Label'
  11. import PageTitle from '@/components/common/PageTitle'
  12. import Pagination from '@/components/common/Pagination'
  13. import TagInput from '@/components/common/TagInput'
  14. import MainArea from '@/components/layout/MainArea'
  15. import { SITE_TITLE } from '@/config'
  16. import { fetchPosts } from '@/lib/posts'
  17. import { postsKeys } from '@/lib/queryKeys'
  18. import { dateString, originalCreatedAtString } from '@/lib/utils'
  19. import type { FC, FormEvent } from 'react'
  20. import type { FetchPostsOrder,
  21. FetchPostsOrderField,
  22. FetchPostsParams } from '@/types'
  23. const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
  24. const t = v?.trim ()
  25. if (t)
  26. qs.set (k, t)
  27. }
  28. const PostSearchPage: FC = () => {
  29. const location = useLocation ()
  30. const navigate = useNavigate ()
  31. const query = useMemo (() => new URLSearchParams (location.search),
  32. [location.search])
  33. const page = Number (query.get ('page') ?? 1)
  34. const limit = Number (query.get ('limit') ?? 20)
  35. const qURL = query.get ('url') ?? ''
  36. const qTitle = query.get ('title') ?? ''
  37. const qTags = query.get ('tags') ?? ''
  38. const qMatch: 'all' | 'any' = query.get ('match') === 'any' ? 'any' : 'all'
  39. const qOriginalCreatedFrom = query.get ('original_created_from') ?? ''
  40. const qOriginalCreatedTo = query.get ('original_created_to') ?? ''
  41. const qCreatedFrom = query.get ('created_from') ?? ''
  42. const qCreatedTo = query.get ('created_to') ?? ''
  43. const qUpdatedFrom = query.get ('updated_from') ?? ''
  44. const qUpdatedTo = query.get ('updated_to') ?? ''
  45. const order = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder
  46. const [createdFrom, setCreatedFrom] = useState<string | null> (null)
  47. const [createdTo, setCreatedTo] = useState<string | null> (null)
  48. const [matchType, setMatchType] = useState<'all' | 'any'> ('all')
  49. const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
  50. const [originalCreatedTo, setOriginalCreatedTo] = useState<string | null> (null)
  51. const [tagsStr, setTagsStr] = useState ('')
  52. const [title, setTitle] = useState ('')
  53. const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
  54. const [updatedTo, setUpdatedTo] = useState<string | null> (null)
  55. const [url, setURL] = useState ('')
  56. const keys: FetchPostsParams = {
  57. tags: qTags, match: qMatch, page, limit,
  58. url: qURL,
  59. title: qTitle,
  60. originalCreatedFrom: qOriginalCreatedFrom,
  61. originalCreatedTo: qOriginalCreatedTo,
  62. createdFrom: qCreatedFrom,
  63. createdTo: qCreatedTo,
  64. updatedFrom: qUpdatedFrom,
  65. updatedTo: qUpdatedTo,
  66. order }
  67. const { data, isLoading: loading } = useQuery ({
  68. queryKey: postsKeys.index (keys),
  69. queryFn: () => fetchPosts (keys) })
  70. const results = data?.posts ?? []
  71. const totalPages = data ? Math.ceil (data.count / limit) : 0
  72. useEffect (() => {
  73. setURL (qURL ?? '')
  74. setTitle (qTitle ?? '')
  75. setTagsStr (qTags ?? '')
  76. setMatchType (qMatch ?? 'all')
  77. setOriginalCreatedFrom (qOriginalCreatedFrom)
  78. setOriginalCreatedTo (qOriginalCreatedTo)
  79. setCreatedFrom (qCreatedFrom)
  80. setCreatedTo (qCreatedTo)
  81. setUpdatedFrom (qUpdatedFrom)
  82. setUpdatedTo (qUpdatedTo)
  83. document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
  84. }, [location.search, qCreatedFrom, qCreatedTo, qMatch, qOriginalCreatedFrom,
  85. qOriginalCreatedTo, qTags, qTitle, qUpdatedFrom, qUpdatedTo, qURL])
  86. const search = async () => {
  87. const qs = new URLSearchParams ()
  88. setIf (qs, 'tags', tagsStr)
  89. setIf (qs, 'url', url)
  90. setIf (qs, 'title', title)
  91. setIf (qs, 'original_created_from', originalCreatedFrom)
  92. setIf (qs, 'original_created_to', originalCreatedTo)
  93. setIf (qs, 'created_from', createdFrom)
  94. setIf (qs, 'created_to', createdTo)
  95. setIf (qs, 'updated_from', updatedFrom)
  96. setIf (qs, 'updated_to', updatedTo)
  97. qs.set ('match', matchType)
  98. qs.set ('page', '1')
  99. qs.set ('order', order)
  100. navigate (`${ location.pathname }?${ qs.toString () }`)
  101. }
  102. const handleSearch = (e: FormEvent) => {
  103. e.preventDefault ()
  104. search ()
  105. }
  106. const defaultDirection = { title: 'asc',
  107. url: 'asc',
  108. original_created_at: 'desc',
  109. created_at: 'desc',
  110. updated_at: 'desc' } as const
  111. return (
  112. <MainArea>
  113. <Helmet>
  114. <title>広場検索 | {SITE_TITLE}</title>
  115. </Helmet>
  116. <div className="max-w-xl">
  117. <PageTitle>広場検索</PageTitle>
  118. <form onSubmit={handleSearch} className="space-y-2">
  119. {/* タイトル */}
  120. <div>
  121. <Label>タイトル</Label>
  122. <input
  123. type="text"
  124. value={title}
  125. onChange={e => setTitle (e.target.value)}
  126. className="w-full border p-2 rounded"/>
  127. </div>
  128. {/* URL */}
  129. <div>
  130. <Label>URL</Label>
  131. <input
  132. type="text"
  133. value={url}
  134. onChange={e => setURL (e.target.value)}
  135. className="w-full border p-2 rounded"/>
  136. </div>
  137. {/* タグ */}
  138. <div>
  139. <Label>タグ</Label>
  140. <TagInput
  141. value={tagsStr}
  142. setValue={setTagsStr}/>
  143. <fieldset className="w-full my-2">
  144. <label>検索区分:</label>
  145. <label className="mx-2">
  146. <input
  147. type="radio"
  148. name="match-type"
  149. checked={matchType === 'all'}
  150. onChange={() => setMatchType ('all')}/>
  151. AND
  152. </label>
  153. <label className="mx-2">
  154. <input
  155. type="radio"
  156. name="match-type"
  157. checked={matchType === 'any'}
  158. onChange={() => setMatchType ('any')}/>
  159. OR
  160. </label>
  161. </fieldset>
  162. </div>
  163. {/* オリジナルの投稿日時 */}
  164. <div>
  165. <Label>オリジナルの投稿日時</Label>
  166. <DateTimeField
  167. value={originalCreatedFrom ?? undefined}
  168. onChange={setOriginalCreatedFrom}/>
  169. <span className="mx-1">〜</span>
  170. <DateTimeField
  171. value={originalCreatedTo ?? undefined}
  172. onChange={setOriginalCreatedTo}/>
  173. </div>
  174. {/* 投稿日時 */}
  175. <div>
  176. <Label>投稿日時</Label>
  177. <DateTimeField
  178. value={createdFrom ?? undefined}
  179. onChange={setCreatedFrom}/>
  180. <span className="mx-1">〜</span>
  181. <DateTimeField
  182. value={createdTo ?? undefined}
  183. onChange={setCreatedTo}/>
  184. </div>
  185. {/* 更新日時 */}
  186. <div>
  187. <Label>更新日時</Label>
  188. <DateTimeField
  189. value={updatedFrom ?? undefined}
  190. onChange={setUpdatedFrom}/>
  191. <span className="mx-1">〜</span>
  192. <DateTimeField
  193. value={updatedTo ?? undefined}
  194. onChange={setUpdatedTo}/>
  195. </div>
  196. {/* 検索 */}
  197. <div className="py-3">
  198. <button
  199. type="submit"
  200. className="bg-blue-500 text-white px-4 py-2 rounded">
  201. 検索
  202. </button>
  203. </div>
  204. </form>
  205. </div>
  206. {loading ? 'Loading...' : (results.length > 0 ? (
  207. <div className="mt-4">
  208. <div className="overflow-x-auto">
  209. <table className="w-full min-w-[1200px] table-fixed border-collapse">
  210. <colgroup>
  211. <col className="w-14"/>
  212. <col className="w-72"/>
  213. <col className="w-80"/>
  214. <col className="w-[24rem]"/>
  215. <col className="w-60"/>
  216. <col className="w-44"/>
  217. <col className="w-44"/>
  218. </colgroup>
  219. <thead className="border-b-2 border-black dark:border-white">
  220. <tr>
  221. <th className="p-2 text-left whitespace-nowrap">投稿</th>
  222. <th className="p-2 text-left whitespace-nowrap">
  223. <SortHeader<FetchPostsOrderField>
  224. by="title"
  225. label="タイトル"
  226. currentOrder={order}
  227. defaultDirection={defaultDirection}/>
  228. </th>
  229. <th className="p-2 text-left whitespace-nowrap">
  230. <SortHeader<FetchPostsOrderField>
  231. by="url"
  232. label="URL"
  233. currentOrder={order}
  234. defaultDirection={defaultDirection}/>
  235. </th>
  236. <th className="p-2 text-left whitespace-nowrap">タグ</th>
  237. <th className="p-2 text-left whitespace-nowrap">
  238. <SortHeader<FetchPostsOrderField>
  239. by="original_created_at"
  240. label="オリジナルの投稿日時"
  241. currentOrder={order}
  242. defaultDirection={defaultDirection}/>
  243. </th>
  244. <th className="p-2 text-left whitespace-nowrap">
  245. <SortHeader<FetchPostsOrderField>
  246. by="created_at"
  247. label="投稿日時"
  248. currentOrder={order}
  249. defaultDirection={defaultDirection}/>
  250. </th>
  251. <th className="p-2 text-left whitespace-nowrap">
  252. <SortHeader<FetchPostsOrderField>
  253. by="updated_at"
  254. label="更新日時"
  255. currentOrder={order}
  256. defaultDirection={defaultDirection}/>
  257. </th>
  258. </tr>
  259. </thead>
  260. <tbody>
  261. {results.map (row => (
  262. <tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700">
  263. <td className="p-2">
  264. <PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}>
  265. <motion.div
  266. layoutId={`page-${ row.id }`}
  267. transition={{ type: 'spring',
  268. stiffness: 500,
  269. damping: 40,
  270. mass: .5 }}>
  271. <img src={row.thumbnail || row.thumbnailBase || undefined}
  272. alt={row.title || row.url}
  273. title={row.title || row.url || undefined}
  274. className="w-8"/>
  275. </motion.div>
  276. </PrefetchLink>
  277. </td>
  278. <td className="p-2 truncate">
  279. <PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}>
  280. {row.title}
  281. </PrefetchLink>
  282. </td>
  283. <td className="p-2 truncate">
  284. <a href={row.url}
  285. title={row.url}
  286. target="_blank"
  287. rel="noopener noreferrer nofollow">
  288. {row.url}
  289. </a>
  290. </td>
  291. <td className="p-2">
  292. {row.tags.map (t => (
  293. <span key={t.id} className="mr-2">
  294. <TagLink tag={t} withWiki={false} withCount={false}/>
  295. </span>))}
  296. </td>
  297. <td className="p-2">
  298. {originalCreatedAtString (row.originalCreatedFrom,
  299. row.originalCreatedBefore)}
  300. </td>
  301. <td className="p-2">{dateString (row.createdAt)}</td>
  302. <td className="p-2">{dateString (row.updatedAt)}</td>
  303. </tr>))}
  304. </tbody>
  305. </table>
  306. </div>
  307. <Pagination page={page} totalPages={totalPages}/>
  308. </div>) : '結果ないよ(笑)')}
  309. </MainArea>)
  310. }
  311. export default PostSearchPage