ぼざクリタグ広場 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.
 
 
 
 
 
 

411 lines
12 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 TagLink from '@/components/TagLink'
  8. import TagSearchBox from '@/components/TagSearchBox'
  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 MainArea from '@/components/layout/MainArea'
  14. import { SITE_TITLE } from '@/config'
  15. import { apiGet } from '@/lib/api'
  16. import { fetchPosts } from '@/lib/posts'
  17. import { postsKeys } from '@/lib/queryKeys'
  18. import { dateString, originalCreatedAtString } from '@/lib/utils'
  19. import type { FC, ChangeEvent, FormEvent, KeyboardEvent } from 'react'
  20. import type { FetchPostsOrder,
  21. FetchPostsOrderField,
  22. FetchPostsParams,
  23. Tag } from '@/types'
  24. const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
  25. const t = v?.trim ()
  26. if (t)
  27. qs.set (k, t)
  28. }
  29. export default (() => {
  30. const location = useLocation ()
  31. const navigate = useNavigate ()
  32. const query = useMemo (() => new URLSearchParams (location.search),
  33. [location.search])
  34. const page = Number (query.get ('page') ?? 1)
  35. const limit = Number (query.get ('limit') ?? 20)
  36. const qURL = query.get ('url') ?? ''
  37. const qTitle = query.get ('title') ?? ''
  38. const qTags = query.get ('tags') ?? ''
  39. const qMatch: 'all' | 'any' = query.get ('match') === 'any' ? 'any' : 'all'
  40. const qOriginalCreatedFrom = query.get ('original_created_from') ?? ''
  41. const qOriginalCreatedTo = query.get ('original_created_to') ?? ''
  42. const qCreatedFrom = query.get ('created_from') ?? ''
  43. const qCreatedTo = query.get ('created_to') ?? ''
  44. const qUpdatedFrom = query.get ('updated_from') ?? ''
  45. const qUpdatedTo = query.get ('updated_to') ?? ''
  46. const order = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder
  47. const [activeIndex, setActiveIndex] = useState (-1)
  48. const [createdFrom, setCreatedFrom] = useState<string | null> (null)
  49. const [createdTo, setCreatedTo] = useState<string | null> (null)
  50. const [matchType, setMatchType] = useState<'all' | 'any'> ('all')
  51. const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
  52. const [originalCreatedTo, setOriginalCreatedTo] = useState<string | null> (null)
  53. const [suggestions, setSuggestions] = useState<Tag[]> ([])
  54. const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
  55. const [tagsStr, setTagsStr] = useState ('')
  56. const [title, setTitle] = useState ('')
  57. const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
  58. const [updatedTo, setUpdatedTo] = useState<string | null> (null)
  59. const [url, setURL] = useState ('')
  60. const keys: FetchPostsParams = {
  61. tags: qTags, match: qMatch, page, limit,
  62. url: qURL,
  63. title: qTitle,
  64. originalCreatedFrom: qOriginalCreatedFrom,
  65. originalCreatedTo: qOriginalCreatedTo,
  66. createdFrom: qCreatedFrom,
  67. createdTo: qCreatedTo,
  68. updatedFrom: qUpdatedFrom,
  69. updatedTo: qUpdatedTo,
  70. order }
  71. const { data, isLoading: loading } = useQuery ({
  72. queryKey: postsKeys.index (keys),
  73. queryFn: () => fetchPosts (keys) })
  74. const results = data?.posts ?? []
  75. const totalPages = data ? Math.ceil (data.count / limit) : 0
  76. useEffect (() => {
  77. setURL (qURL ?? '')
  78. setTitle (qTitle ?? '')
  79. setTagsStr (qTags ?? '')
  80. setMatchType (qMatch ?? 'all')
  81. setOriginalCreatedFrom (qOriginalCreatedFrom)
  82. setOriginalCreatedTo (qOriginalCreatedTo)
  83. setCreatedFrom (qCreatedFrom)
  84. setCreatedTo (qCreatedTo)
  85. setUpdatedFrom (qUpdatedFrom)
  86. setUpdatedTo (qUpdatedTo)
  87. document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
  88. }, [location.search])
  89. const SortHeader = ({ by, label }: { by: FetchPostsOrderField; label: string }) => {
  90. const [fld, dir] = order.split (':')
  91. const qs = new URLSearchParams (location.search)
  92. const nextDir =
  93. (by === fld)
  94. ? (dir === 'asc' ? 'desc' : 'asc')
  95. : (['title', 'url'].includes (by) ? 'asc' : 'desc')
  96. qs.set ('order', `${ by }:${ nextDir }`)
  97. qs.set ('page', '1')
  98. return (
  99. <PrefetchLink
  100. className="text-inherit visited:text-inherit hover:text-inherit"
  101. to={`${ location.pathname }?${ qs.toString () }`}>
  102. <span className="font-bold">
  103. {label}
  104. {by === fld && (dir === 'asc' ? ' ▲' : ' ▼')}
  105. </span>
  106. </PrefetchLink>)
  107. }
  108. // TODO: TagSearch からのコピペのため,共通化を考へる.
  109. const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
  110. setTagsStr (ev.target.value)
  111. const q = ev.target.value.trim ().split (' ').at (-1)
  112. if (!(q))
  113. {
  114. setSuggestions ([])
  115. return
  116. }
  117. const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
  118. setSuggestions (data.filter (t => t.postCount > 0))
  119. if (suggestions.length > 0)
  120. setSuggestionsVsbl (true)
  121. }
  122. // TODO: TagSearch からのコピペのため,共通化を考へる.
  123. const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
  124. switch (ev.key)
  125. {
  126. case 'ArrowDown':
  127. ev.preventDefault ()
  128. setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
  129. setSuggestionsVsbl (true)
  130. break
  131. case 'ArrowUp':
  132. ev.preventDefault ()
  133. setActiveIndex (i => Math.max (i - 1, -1))
  134. setSuggestionsVsbl (true)
  135. break
  136. case 'Enter':
  137. if (activeIndex < 0)
  138. break
  139. ev.preventDefault ()
  140. const selected = suggestions[activeIndex]
  141. selected && handleTagSelect (selected)
  142. break
  143. case 'Escape':
  144. ev.preventDefault ()
  145. setSuggestionsVsbl (false)
  146. break
  147. }
  148. if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
  149. {
  150. setSuggestionsVsbl (false)
  151. }
  152. }
  153. const search = async () => {
  154. const qs = new URLSearchParams ()
  155. setIf (qs, 'tags', tagsStr)
  156. setIf (qs, 'url', url)
  157. setIf (qs, 'title', title)
  158. setIf (qs, 'original_created_from', originalCreatedFrom)
  159. setIf (qs, 'original_created_to', originalCreatedTo)
  160. setIf (qs, 'created_from', createdFrom)
  161. setIf (qs, 'created_to', createdTo)
  162. setIf (qs, 'updated_from', updatedFrom)
  163. setIf (qs, 'updated_to', updatedTo)
  164. qs.set ('match', matchType)
  165. qs.set ('page', String ('1'))
  166. qs.set ('order', order)
  167. navigate (`${ location.pathname }?${ qs.toString () }`)
  168. }
  169. // TODO: TagSearch からのコピペのため,共通化を考へる.
  170. const handleTagSelect = (tag: Tag) => {
  171. const parts = tagsStr.split (' ')
  172. parts[parts.length - 1] = tag.name
  173. setTagsStr (parts.join (' ') + ' ')
  174. setSuggestions ([])
  175. setActiveIndex (-1)
  176. }
  177. const handleSearch = (e: FormEvent) => {
  178. e.preventDefault ()
  179. search ()
  180. }
  181. return (
  182. <MainArea>
  183. <Helmet>
  184. <title>広場検索 | {SITE_TITLE}</title>
  185. </Helmet>
  186. <div className="max-w-xl">
  187. <PageTitle>広場検索</PageTitle>
  188. <form onSubmit={handleSearch} className="space-y-2">
  189. {/* タイトル */}
  190. <div>
  191. <Label>タイトル</Label>
  192. <input
  193. type="text"
  194. value={title}
  195. onChange={e => setTitle (e.target.value)}
  196. className="w-full border p-2 rounded"/>
  197. </div>
  198. {/* URL */}
  199. <div>
  200. <Label>URL</Label>
  201. <input
  202. type="text"
  203. value={url}
  204. onChange={e => setURL (e.target.value)}
  205. className="w-full border p-2 rounded"/>
  206. </div>
  207. {/* タグ */}
  208. <div className="relative">
  209. <Label>タグ</Label>
  210. <input
  211. type="text"
  212. value={tagsStr}
  213. onChange={whenChanged}
  214. onFocus={() => setSuggestionsVsbl (true)}
  215. onBlur={() => setSuggestionsVsbl (false)}
  216. onKeyDown={handleKeyDown}
  217. className="w-full border p-2 rounded"/>
  218. <TagSearchBox
  219. suggestions={
  220. suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
  221. activeIndex={activeIndex}
  222. onSelect={handleTagSelect}/>
  223. <fieldset className="w-full my-2">
  224. <label>検索区分:</label>
  225. <label className="mx-2">
  226. <input
  227. type="radio"
  228. name="match-type"
  229. checked={matchType === 'all'}
  230. onChange={() => setMatchType ('all')}/>
  231. AND
  232. </label>
  233. <label className="mx-2">
  234. <input
  235. type="radio"
  236. name="match-type"
  237. checked={matchType === 'any'}
  238. onChange={() => setMatchType ('any')}/>
  239. OR
  240. </label>
  241. </fieldset>
  242. </div>
  243. {/* オリジナルの投稿日時 */}
  244. <div>
  245. <Label>オリジナルの投稿日時</Label>
  246. <DateTimeField
  247. value={originalCreatedFrom ?? undefined}
  248. onChange={setOriginalCreatedFrom}/>
  249. <span className="mx-1">〜</span>
  250. <DateTimeField
  251. value={originalCreatedTo ?? undefined}
  252. onChange={setOriginalCreatedTo}/>
  253. </div>
  254. {/* 投稿日時 */}
  255. <div>
  256. <Label>投稿日時</Label>
  257. <DateTimeField
  258. value={createdFrom ?? undefined}
  259. onChange={setCreatedFrom}/>
  260. <span className="mx-1">〜</span>
  261. <DateTimeField
  262. value={createdTo ?? undefined}
  263. onChange={setCreatedTo}/>
  264. </div>
  265. {/* 更新日時 */}
  266. <div>
  267. <Label>更新日時</Label>
  268. <DateTimeField
  269. value={updatedFrom ?? undefined}
  270. onChange={setUpdatedFrom}/>
  271. <span className="mx-1">〜</span>
  272. <DateTimeField
  273. value={updatedTo ?? undefined}
  274. onChange={setUpdatedTo}/>
  275. </div>
  276. {/* 検索 */}
  277. <div className="py-3">
  278. <button
  279. type="submit"
  280. className="bg-blue-500 text-white px-4 py-2 rounded">
  281. 検索
  282. </button>
  283. </div>
  284. </form>
  285. </div>
  286. {loading ? 'Loading...' : (results.length > 0 ? (
  287. <div className="mt-4">
  288. <div className="overflow-x-auto">
  289. <table className="w-full min-w-[1200px] table-fixed border-collapse">
  290. <colgroup>
  291. <col className="w-14"/>
  292. <col className="w-72"/>
  293. <col className="w-80"/>
  294. <col className="w-[24rem]"/>
  295. <col className="w-60"/>
  296. <col className="w-44"/>
  297. <col className="w-44"/>
  298. </colgroup>
  299. <thead className="border-b-2 border-black dark:border-white">
  300. <tr>
  301. <th className="p-2 text-left whitespace-nowrap">投稿</th>
  302. <th className="p-2 text-left whitespace-nowrap">
  303. <SortHeader by="title" label="タイトル"/>
  304. </th>
  305. <th className="p-2 text-left whitespace-nowrap">
  306. <SortHeader by="url" label="URL"/>
  307. </th>
  308. <th className="p-2 text-left whitespace-nowrap">タグ</th>
  309. <th className="p-2 text-left whitespace-nowrap">
  310. <SortHeader by="original_created_at" label="オリジナルの投稿日時"/>
  311. </th>
  312. <th className="p-2 text-left whitespace-nowrap">
  313. <SortHeader by="created_at" label="投稿日時"/>
  314. </th>
  315. <th className="p-2 text-left whitespace-nowrap">
  316. <SortHeader by="updated_at" label="更新日時"/>
  317. </th>
  318. </tr>
  319. </thead>
  320. <tbody>
  321. {results.map (row => (
  322. <tr key={row.id} className={'even:bg-gray-100 dark:even:bg-gray-700'}>
  323. <td className="p-2">
  324. <PrefetchLink to={`/posts/${ row.id }`} title={row.title}>
  325. <motion.div
  326. layoutId={`page-${ row.id }`}
  327. transition={{ type: 'spring',
  328. stiffness: 500,
  329. damping: 40,
  330. mass: .5 }}>
  331. <img src={row.thumbnail || row.thumbnailBase || undefined}
  332. alt={row.title || row.url}
  333. title={row.title || row.url || undefined}
  334. className="w-8"/>
  335. </motion.div>
  336. </PrefetchLink>
  337. </td>
  338. <td className="p-2 truncate">
  339. <PrefetchLink to={`/posts/${ row.id }`} title={row.title}>
  340. {row.title}
  341. </PrefetchLink>
  342. </td>
  343. <td className="p-2 truncate">
  344. <a href={row.url}
  345. title={row.url}
  346. target="_blank"
  347. rel="noopener noreferrer nofollow">
  348. {row.url}
  349. </a>
  350. </td>
  351. <td className="p-2">
  352. {row.tags.map (t => (
  353. <span key={t.id} className="mr-2">
  354. <TagLink tag={t} withWiki={false} withCount={false}/>
  355. </span>))}
  356. </td>
  357. <td className="p-2">
  358. {originalCreatedAtString (row.originalCreatedFrom,
  359. row.originalCreatedBefore)}
  360. </td>
  361. <td className="p-2">{dateString (row.createdAt)}</td>
  362. <td className="p-2">{dateString (row.updatedAt)}</td>
  363. </tr>))}
  364. </tbody>
  365. </table>
  366. </div>
  367. <Pagination page={page} totalPages={totalPages}/>
  368. </div>) : '結果ないよ(笑)')}
  369. </MainArea>)
  370. }) satisfies FC