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

211 lines
7.3 KiB

  1. import React, { useEffect, useState, useRef } from 'react'
  2. import { Link, useLocation, useParams, useNavigate } from 'react-router-dom'
  3. import axios from 'axios'
  4. import { API_BASE_URL, SITE_TITLE } from '../config'
  5. import NicoViewer from '../components/NicoViewer'
  6. import { Button } from '@/components/ui/button'
  7. import { toast } from '@/components/ui/use-toast'
  8. import { cn } from '@/lib/utils'
  9. import type { Post, Tag } from '@/types'
  10. type Props = { posts: Post[]
  11. setPosts: (posts: Post[]) => void }
  12. const PostNewPage = () => {
  13. const location = useLocation ()
  14. const navigate = useNavigate ()
  15. const [title, setTitle] = useState ('')
  16. const [titleAutoFlg, setTitleAutoFlg] = useState (true)
  17. const [titleLoading, setTitleLoading] = useState (false)
  18. const [url, setURL] = useState ('')
  19. const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
  20. const [thumbnailPreview, setThumbnailPreview] = useState<string> ('')
  21. const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true)
  22. const [thumbnailLoading, setThumbnailLoading] = useState (false)
  23. const [tags, setTags] = useState<Tag[]> ([])
  24. const [tagIds, setTagIds] = useState<number[]> ([])
  25. const previousURLRef = useRef ('')
  26. const handleSubmit = () => {
  27. const formData = new FormData ()
  28. formData.append ('title', title)
  29. formData.append ('url', url)
  30. formData.append ('tags', JSON.stringify (tagIds))
  31. formData.append ('thumbnail', thumbnailFile)
  32. void (axios.post (`${ API_BASE_URL }/posts`, formData, { headers: {
  33. 'Content-Type': 'multipart/form-data',
  34. 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } })
  35. .then (() => {
  36. toast ({ title: '投稿成功!' })
  37. navigate ('/posts')
  38. })
  39. .catch (e => toast ({ title: '投稿失敗',
  40. description: '入力を確認してください。' })))
  41. }
  42. document.title = `広場に投稿を追加 | ${ SITE_TITLE }`
  43. useEffect (() => {
  44. void (axios.get ('/api/tags')
  45. .then (res => setTags (res.data))
  46. .catch (() => toast ({ title: 'タグ一覧の取得失敗' })))
  47. }, [])
  48. useEffect (() => {
  49. if (titleAutoFlg && url)
  50. fetchTitle ()
  51. }, [titleAutoFlg])
  52. useEffect (() => {
  53. if (thumbnailAutoFlg && url)
  54. fetchThumbnail ()
  55. }, [thumbnailAutoFlg])
  56. const handleURLBlur = () => {
  57. if (!(url) || url === previousURLRef.current)
  58. return
  59. if (titleAutoFlg)
  60. fetchTitle ()
  61. if (thumbnailAutoFlg)
  62. fetchThumbnail ()
  63. previousURLRef.current = url
  64. }
  65. const fetchTitle = () => {
  66. setTitle ('')
  67. setTitleLoading (true)
  68. void (axios.get (`${ API_BASE_URL }/preview/title`, {
  69. params: { url },
  70. headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } })
  71. .then (res => {
  72. setTitle (res.data.title || '')
  73. setTitleLoading (false)
  74. })
  75. .finally (() => setTitleLoading (false)))
  76. }
  77. const fetchThumbnail = () => {
  78. setThumbnailPreview ('')
  79. setThumbnailFile (null)
  80. setThumbnailLoading (true)
  81. if (thumbnailPreview)
  82. URL.revokeObjectURL (thumbnailPreview)
  83. void (axios.get (`${ API_BASE_URL }/preview/thumbnail`, {
  84. params: { url },
  85. headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' },
  86. responseType: 'blob' })
  87. .then (res => {
  88. const imageURL = URL.createObjectURL (res.data)
  89. setThumbnailPreview (imageURL)
  90. setThumbnailFile (new File ([res.data],
  91. 'thumbnail.png',
  92. { type: res.data.type || 'image/png' }))
  93. setThumbnailLoading (false)
  94. })
  95. .finally (() => setThumbnailLoading (false)))
  96. }
  97. return (
  98. <div className="max-w-xl mx-auto p-4 space-y-4">
  99. <h1 className="text-2xl font-bold mb-2">広場に投稿を追加する</h1>
  100. {/* URL */}
  101. <div>
  102. <label className="block font-semibold mb-1">URL</label>
  103. <input type="text"
  104. placeholder="例:https://www.nicovideo.jp/watch/..."
  105. value={url}
  106. onChange={e => setURL (e.target.value)}
  107. className="w-full border p-2 rounded"
  108. onBlur={handleURLBlur} />
  109. </div>
  110. {/* タイトル */}
  111. <div>
  112. <div className="flex gap-2 mb-1">
  113. <label className="flex-1 block font-semibold">タイトル</label>
  114. <label className="flex items-center block gap-1">
  115. <input type="checkbox"
  116. checked={titleAutoFlg}
  117. onChange={e => setTitleAutoFlg (e.target.checked)} />
  118. 自動
  119. </label>
  120. </div>
  121. <input type="text"
  122. className="w-full border rounded p-2"
  123. value={title}
  124. placeholder={titleLoading ? 'Loading...' : ''}
  125. onChange={e => setTitle (e.target.value)}
  126. disabled={titleAutoFlg} />
  127. </div>
  128. {/* サムネール */}
  129. <div>
  130. <div className="flex gap-2 mb-1">
  131. <label className="block font-semibold flex-1">サムネール</label>
  132. <label className="flex items-center gap-1">
  133. <input type="checkbox"
  134. checked={thumbnailAutoFlg}
  135. onChange={e => setThumbnailAutoFlg (e.target.checked)} />
  136. 自動
  137. </label>
  138. </div>
  139. {thumbnailAutoFlg
  140. ? (thumbnailLoading
  141. ? <p className="text-gray-500 text-sm">Loading...</p>
  142. : !(thumbnailPreview) && (
  143. <p className="text-gray-500 text-sm">
  144. URL から自動取得されます。
  145. </p>))
  146. : (
  147. <input type="file"
  148. accept="image/*"
  149. onChange={e => {
  150. const file = e.target.files?.[0]
  151. if (file)
  152. {
  153. setThumbnailFile (file)
  154. setThumbnailPreview (URL.createObjectURL (file))
  155. }
  156. }} />)}
  157. {thumbnailPreview && (
  158. <img src={thumbnailPreview}
  159. alt="preview"
  160. className="mt-2 max-h-48 rounded border" />)}
  161. </div>
  162. {/* タグ */}
  163. <div>
  164. <label className="block font-semibold">タグ</label>
  165. <select multiple
  166. value={tagIds.map (String)}
  167. onChange={e => {
  168. const values = Array.from (e.target.selectedOptions).map (o => Number (o.value))
  169. setTagIds (values)
  170. }}
  171. className="w-full p-2 border rounded h-32">
  172. {tags.map ((tag: Tag) => (
  173. <option key={tag.id} value={tag.id}>
  174. {tag.name}
  175. </option>))}
  176. </select>
  177. </div>
  178. {/* 送信 */}
  179. <button onClick={handleSubmit}
  180. className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
  181. disabled={titleLoading || thumbnailLoading}>
  182. 追加
  183. </button>
  184. </div>)
  185. }
  186. export default PostNewPage