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

340 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. export default (() => {
  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])
  85. const search = async () => {
  86. const qs = new URLSearchParams ()
  87. setIf (qs, 'tags', tagsStr)
  88. setIf (qs, 'url', url)
  89. setIf (qs, 'title', title)
  90. setIf (qs, 'original_created_from', originalCreatedFrom)
  91. setIf (qs, 'original_created_to', originalCreatedTo)
  92. setIf (qs, 'created_from', createdFrom)
  93. setIf (qs, 'created_to', createdTo)
  94. setIf (qs, 'updated_from', updatedFrom)
  95. setIf (qs, 'updated_to', updatedTo)
  96. qs.set ('match', matchType)
  97. qs.set ('page', '1')
  98. qs.set ('order', order)
  99. navigate (`${ location.pathname }?${ qs.toString () }`)
  100. }
  101. const handleSearch = (e: FormEvent) => {
  102. e.preventDefault ()
  103. search ()
  104. }
  105. const defaultDirection = { title: 'asc',
  106. url: 'asc',
  107. original_created_at: 'desc',
  108. created_at: 'desc',
  109. updated_at: 'desc' } as const
  110. return (
  111. <MainArea>
  112. <Helmet>
  113. <title>広場検索 | {SITE_TITLE}</title>
  114. </Helmet>
  115. <div className="max-w-xl">
  116. <PageTitle>広場検索</PageTitle>
  117. <form onSubmit={handleSearch} className="space-y-2">
  118. {/* タイトル */}
  119. <div>
  120. <Label>タイトル</Label>
  121. <input
  122. type="text"
  123. value={title}
  124. onChange={e => setTitle (e.target.value)}
  125. className="w-full border p-2 rounded"/>
  126. </div>
  127. {/* URL */}
  128. <div>
  129. <Label>URL</Label>
  130. <input
  131. type="text"
  132. value={url}
  133. onChange={e => setURL (e.target.value)}
  134. className="w-full border p-2 rounded"/>
  135. </div>
  136. {/* タグ */}
  137. <div>
  138. <Label>タグ</Label>
  139. <TagInput
  140. value={tagsStr}
  141. setValue={setTagsStr}/>
  142. <fieldset className="w-full my-2">
  143. <label>検索区分:</label>
  144. <label className="mx-2">
  145. <input
  146. type="radio"
  147. name="match-type"
  148. checked={matchType === 'all'}
  149. onChange={() => setMatchType ('all')}/>
  150. AND
  151. </label>
  152. <label className="mx-2">
  153. <input
  154. type="radio"
  155. name="match-type"
  156. checked={matchType === 'any'}
  157. onChange={() => setMatchType ('any')}/>
  158. OR
  159. </label>
  160. </fieldset>
  161. </div>
  162. {/* オリジナルの投稿日時 */}
  163. <div>
  164. <Label>オリジナルの投稿日時</Label>
  165. <DateTimeField
  166. value={originalCreatedFrom ?? undefined}
  167. onChange={setOriginalCreatedFrom}/>
  168. <span className="mx-1">〜</span>
  169. <DateTimeField
  170. value={originalCreatedTo ?? undefined}
  171. onChange={setOriginalCreatedTo}/>
  172. </div>
  173. {/* 投稿日時 */}
  174. <div>
  175. <Label>投稿日時</Label>
  176. <DateTimeField
  177. value={createdFrom ?? undefined}
  178. onChange={setCreatedFrom}/>
  179. <span className="mx-1">〜</span>
  180. <DateTimeField
  181. value={createdTo ?? undefined}
  182. onChange={setCreatedTo}/>
  183. </div>
  184. {/* 更新日時 */}
  185. <div>
  186. <Label>更新日時</Label>
  187. <DateTimeField
  188. value={updatedFrom ?? undefined}
  189. onChange={setUpdatedFrom}/>
  190. <span className="mx-1">〜</span>
  191. <DateTimeField
  192. value={updatedTo ?? undefined}
  193. onChange={setUpdatedTo}/>
  194. </div>
  195. {/* 検索 */}
  196. <div className="py-3">
  197. <button
  198. type="submit"
  199. className="bg-blue-500 text-white px-4 py-2 rounded">
  200. 検索
  201. </button>
  202. </div>
  203. </form>
  204. </div>
  205. {loading ? 'Loading...' : (results.length > 0 ? (
  206. <div className="mt-4">
  207. <div className="overflow-x-auto">
  208. <table className="w-full min-w-[1200px] table-fixed border-collapse">
  209. <colgroup>
  210. <col className="w-14"/>
  211. <col className="w-72"/>
  212. <col className="w-80"/>
  213. <col className="w-[24rem]"/>
  214. <col className="w-60"/>
  215. <col className="w-44"/>
  216. <col className="w-44"/>
  217. </colgroup>
  218. <thead className="border-b-2 border-black dark:border-white">
  219. <tr>
  220. <th className="p-2 text-left whitespace-nowrap">投稿</th>
  221. <th className="p-2 text-left whitespace-nowrap">
  222. <SortHeader<FetchPostsOrderField>
  223. by="title"
  224. label="タイトル"
  225. currentOrder={order}
  226. defaultDirection={defaultDirection}/>
  227. </th>
  228. <th className="p-2 text-left whitespace-nowrap">
  229. <SortHeader<FetchPostsOrderField>
  230. by="url"
  231. label="URL"
  232. currentOrder={order}
  233. defaultDirection={defaultDirection}/>
  234. </th>
  235. <th className="p-2 text-left whitespace-nowrap">タグ</th>
  236. <th className="p-2 text-left whitespace-nowrap">
  237. <SortHeader<FetchPostsOrderField>
  238. by="original_created_at"
  239. label="オリジナルの投稿日時"
  240. currentOrder={order}
  241. defaultDirection={defaultDirection}/>
  242. </th>
  243. <th className="p-2 text-left whitespace-nowrap">
  244. <SortHeader<FetchPostsOrderField>
  245. by="created_at"
  246. label="投稿日時"
  247. currentOrder={order}
  248. defaultDirection={defaultDirection}/>
  249. </th>
  250. <th className="p-2 text-left whitespace-nowrap">
  251. <SortHeader<FetchPostsOrderField>
  252. by="updated_at"
  253. label="更新日時"
  254. currentOrder={order}
  255. defaultDirection={defaultDirection}/>
  256. </th>
  257. </tr>
  258. </thead>
  259. <tbody>
  260. {results.map (row => (
  261. <tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700">
  262. <td className="p-2">
  263. <PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}>
  264. <motion.div
  265. layoutId={`page-${ row.id }`}
  266. transition={{ type: 'spring',
  267. stiffness: 500,
  268. damping: 40,
  269. mass: .5 }}>
  270. <img src={row.thumbnail || row.thumbnailBase || undefined}
  271. alt={row.title || row.url}
  272. title={row.title || row.url || undefined}
  273. className="w-8"/>
  274. </motion.div>
  275. </PrefetchLink>
  276. </td>
  277. <td className="p-2 truncate">
  278. <PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}>
  279. {row.title}
  280. </PrefetchLink>
  281. </td>
  282. <td className="p-2 truncate">
  283. <a href={row.url}
  284. title={row.url}
  285. target="_blank"
  286. rel="noopener noreferrer nofollow">
  287. {row.url}
  288. </a>
  289. </td>
  290. <td className="p-2">
  291. {row.tags.map (t => (
  292. <span key={t.id} className="mr-2">
  293. <TagLink tag={t} withWiki={false} withCount={false}/>
  294. </span>))}
  295. </td>
  296. <td className="p-2">
  297. {originalCreatedAtString (row.originalCreatedFrom,
  298. row.originalCreatedBefore)}
  299. </td>
  300. <td className="p-2">{dateString (row.createdAt)}</td>
  301. <td className="p-2">{dateString (row.updatedAt)}</td>
  302. </tr>))}
  303. </tbody>
  304. </table>
  305. </div>
  306. <Pagination page={page} totalPages={totalPages}/>
  307. </div>) : '結果ないよ(笑)')}
  308. </MainArea>)
  309. }) satisfies FC