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.
 
 
 
 
 
 

294 lines
7.9 KiB

  1. import axios from 'axios'
  2. import toCamel from 'camelcase-keys'
  3. import { useEffect, useRef, useState } from 'react'
  4. import { FaThumbsDown, FaThumbsUp } from 'react-icons/fa'
  5. import { useParams, useSearchParams } from 'react-router-dom'
  6. import ThreadCanvas from '@/components/threads/ThreadCanvas'
  7. import { API_BASE_URL } from '@/config'
  8. import type { ThreadCanvasHandle } from '@/components/threads/ThreadCanvas'
  9. import type { Post, Thread } from '@/types'
  10. const Sort = {
  11. Newest: 'newest',
  12. Oldest: 'oldest',
  13. Likes: 'likes',
  14. Dislikes: 'dislikes' } as const
  15. type Sort = (typeof Sort)[keyof typeof Sort]
  16. const reformPost = (post: Post): Post => ({
  17. ...post,
  18. createdAt: (new Date (post.createdAt)).toLocaleString ('ja-JP-u-ca-japanese') })
  19. export default () => {
  20. const { id } = useParams ()
  21. const canvasRef = useRef<ThreadCanvasHandle> (null)
  22. const [searchParams, setSearchParams] = useSearchParams ()
  23. const sort = searchParams.get ('sort') ?? 'created_at'
  24. const order = searchParams.get ('order') ?? 'desc'
  25. const sortKey = (() => {
  26. if (sort === 'score')
  27. {
  28. if (order === 'asc')
  29. return Sort.Dislikes
  30. return Sort.Likes
  31. }
  32. else
  33. {
  34. if (order === 'asc')
  35. return Sort.Oldest
  36. return Sort.Newest
  37. }
  38. }) ()
  39. const [loading, setLoading] = useState (true)
  40. const [message, setMessage] = useState ('')
  41. const [name, setName] = useState ('')
  42. const [password, setPassword] = useState ('')
  43. const [posts, setPosts] = useState<Post[]> ([])
  44. const [sending, setSending] = useState (false)
  45. const [thread, setThread] = useState<Thread | null> (null)
  46. const [withImage, setWithImage] = useState (false)
  47. const handleSend = async () => {
  48. if (!(message) && !(withImage))
  49. {
  50. alert ('メッセージ書くか画像描くかどっちかはしろ.')
  51. return
  52. }
  53. setSending (true)
  54. try
  55. {
  56. const form = new FormData
  57. if (withImage)
  58. {
  59. if (!(canvasRef.current))
  60. return
  61. const { dataURL } = await canvasRef.current.exportAll ()
  62. const blob = await (await fetch (dataURL)).blob ()
  63. form.append ('image', new File ([blob], `kekec_${ Date.now () }.png`, { type: 'image/png' }))
  64. }
  65. form.append ('name', name)
  66. form.append ('message', message)
  67. form.append ('password', password)
  68. const res = await axios.post (`${ API_BASE_URL }/threads/${ id }/posts`,
  69. form,
  70. { headers: { 'Content-Type': 'multipart/form-data' } })
  71. const data: Post = toCamel (res.data as any, { deep: true })
  72. setPosts(prev => [reformPost (data), ...prev])
  73. setMessage ('')
  74. if (withImage)
  75. {
  76. canvasRef.current!.clear ()
  77. setWithImage (false)
  78. }
  79. localStorage.setItem ('name', name)
  80. localStorage.setItem ('password', password)
  81. }
  82. finally
  83. {
  84. setSending (false)
  85. }
  86. }
  87. useEffect (() => {
  88. const nameRaw = localStorage.getItem ('name')
  89. nameRaw && setName (nameRaw)
  90. const passRaw = localStorage.getItem ('password')
  91. passRaw && setPassword (passRaw)
  92. const fetchThread = async () => {
  93. try
  94. {
  95. const res = await axios.get (`${ API_BASE_URL }/threads/${ id }`)
  96. setThread (toCamel (res.data as any, { deep: true }) as Thread)
  97. }
  98. catch
  99. {
  100. setThread (null)
  101. }
  102. }
  103. fetchThread ()
  104. }, [])
  105. useEffect (() => {
  106. const fetchPosts = async () => {
  107. try
  108. {
  109. const res = await axios.get (`${ API_BASE_URL }/threads/${ id }/posts`,
  110. { params: { sort, order } })
  111. const data = toCamel (res.data as any, { deep: true }) as Post[]
  112. setPosts (data.map (reformPost))
  113. }
  114. catch
  115. {
  116. setPosts ([])
  117. }
  118. setLoading (false)
  119. }
  120. fetchPosts ()
  121. }, [sortKey])
  122. useEffect (() => {
  123. if (!(thread))
  124. return
  125. document.title = `${ thread.name } - キケッツチャンネル お絵描き掲示板`
  126. }, [thread])
  127. return (
  128. <>
  129. <div className="mb-16">
  130. <div>
  131. <label>名前:</label>
  132. <input type="text"
  133. value={name}
  134. onChange={ev => setName (ev.target.value)} />
  135. </div>
  136. <div>
  137. <label>削除用パスワード:</label>
  138. <input type="password"
  139. value={password}
  140. onChange={ev => setPassword (ev.target.value)} />
  141. </div>
  142. <div>
  143. <label>メッセージ:</label>
  144. <textarea value={message}
  145. onChange={ev => setMessage (ev.target.value)} />
  146. </div>
  147. <div>
  148. <label>
  149. <input type="checkbox"
  150. checked={withImage}
  151. onChange={ev => setWithImage (ev.target.checked)} />
  152. イラストを投稿に含める
  153. </label>
  154. </div>
  155. <ThreadCanvas ref={canvasRef} visible={withImage} />
  156. <div>
  157. <button onClick={handleSend} disabled={sending}>
  158. {sending ? '送信中...' : '送信'}
  159. </button>
  160. </div>
  161. </div>
  162. {loading ? 'Loading...' : (
  163. posts.length > 0
  164. ? (
  165. <>
  166. <div>
  167. <label>
  168. 並べ替え:
  169. <select value={sortKey} onChange={ev => {
  170. switch (ev.target.value)
  171. {
  172. case Sort.Newest:
  173. searchParams.set ('sort', 'created_at')
  174. searchParams.set ('order', 'desc')
  175. break
  176. case Sort.Likes:
  177. searchParams.set ('sort', 'score')
  178. searchParams.set ('order', 'desc')
  179. break
  180. case Sort.Dislikes:
  181. searchParams.set ('sort', 'score')
  182. searchParams.set ('order', 'asc')
  183. break
  184. case Sort.Oldest:
  185. searchParams.set ('sort', 'created_at')
  186. searchParams.set ('order', 'asc')
  187. break
  188. }
  189. setSearchParams (searchParams)
  190. }}>
  191. <option value={Sort.Newest}>投稿が新しい順</option>
  192. <option value={Sort.Likes}>評価が高い順</option>
  193. <option value={Sort.Dislikes}>評価が低い順</option>
  194. <option value={Sort.Oldest}>投稿が古い順</option>
  195. </select>
  196. </label>
  197. </div>
  198. {posts.map (post => (
  199. <div key={post.id}
  200. id={post.postNo.toString ()}
  201. className="bg-white dark:bg-gray-800 p-3 m-4
  202. border border-gray-400 rounded-xl
  203. text-center">
  204. <div className="flex justify-between items-center px-2 py-1">
  205. <span>{post.postNo}: {post.name || '名なしさん'}</span>
  206. <span>{post.createdAt}</span>
  207. </div>
  208. <div className="flex items-center px-4 pt-1 pb-3">
  209. <a className="text-blue-600 dark:text-blue-300 mr-1 whitespace-nowrap"
  210. href="#"
  211. onClick={async ev => {
  212. ev.preventDefault ()
  213. try
  214. {
  215. await axios.post (`${ API_BASE_URL }/posts/${ post.id }/bad`)
  216. setPosts (prev => prev.map (p => (p.id === post.id
  217. ? { ...p, bad: p.bad + 1 }
  218. : p)))
  219. }
  220. catch
  221. {
  222. ;
  223. }
  224. }}>
  225. <FaThumbsDown className="inline" /> {post.bad}
  226. </a>
  227. <div className="h-2 bg-blue-600 dark:bg-blue-300"
  228. style={{ width: `${ post.good + post.bad === 0
  229. ? 50
  230. : post.bad * 100 / (post.good + post.bad) }%` }}>
  231. </div>
  232. <div className="h-2 bg-red-600 dark:bg-red-300"
  233. style={{ width: `${ post.good + post.bad === 0
  234. ? 50
  235. : post.good * 100 / (post.good + post.bad) }%` }}>
  236. </div>
  237. <a className="text-red-600 dark:text-red-300 ml-1 whitespace-nowrap"
  238. href="#"
  239. onClick={async ev => {
  240. ev.preventDefault ()
  241. try
  242. {
  243. await axios.post (`${ API_BASE_URL }/posts/${ post.id }/good`)
  244. setPosts (prev => prev.map (p => (p.id === post.id
  245. ? { ...p, good: p.good + 1 }
  246. : p)))
  247. }
  248. catch
  249. {
  250. ;
  251. }
  252. }}>
  253. {post.good} <FaThumbsUp className="inline" />
  254. </a>
  255. </div>
  256. {post.deletedAt
  257. ? <em className="font-bold">削除されました.</em>
  258. : (
  259. <>
  260. {post.message && (
  261. <div className="text-left px-4 my-2">
  262. {post.message}
  263. </div>)}
  264. {post.imageUrl && (
  265. <div className="bg-white inline-block border border-black dark:border-white">
  266. <img src={`${ API_BASE_URL }${ post.imageUrl }`} />
  267. </div>)}
  268. </>)}
  269. </div>))}
  270. </>)
  271. : 'レスないよ(笑).')}
  272. </>)
  273. }