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

220 lines
6.5 KiB

  1. import { useCallback, useEffect, useState, useRef } from 'react'
  2. import { Helmet } from 'react-helmet-async'
  3. import { useNavigate } from 'react-router-dom'
  4. import PostFormTagsArea from '@/components/PostFormTagsArea'
  5. import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
  6. import Form from '@/components/common/Form'
  7. import Label from '@/components/common/Label'
  8. import PageTitle from '@/components/common/PageTitle'
  9. import MainArea from '@/components/layout/MainArea'
  10. import { Button } from '@/components/ui/button'
  11. import { toast } from '@/components/ui/use-toast'
  12. import { SITE_TITLE } from '@/config'
  13. import { apiGet, apiPost } from '@/lib/api'
  14. import Forbidden from '@/pages/Forbidden'
  15. import type { FC } from 'react'
  16. import type { User } from '@/types'
  17. type Props = { user: User | null }
  18. const PostNewPage: FC<Props> = ({ user }) => {
  19. const editable = ['admin', 'member'].some (r => user?.role === r)
  20. const navigate = useNavigate ()
  21. const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null)
  22. const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
  23. const [parentPostIds, setParentPostIds] = useState ('')
  24. const [tags, setTags] = useState ('')
  25. const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true)
  26. const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
  27. const [thumbnailLoading, setThumbnailLoading] = useState (false)
  28. const [thumbnailPreview, setThumbnailPreview] = useState<string> ('')
  29. const [title, setTitle] = useState ('')
  30. const [titleAutoFlg, setTitleAutoFlg] = useState (true)
  31. const [titleLoading, setTitleLoading] = useState (false)
  32. const [url, setURL] = useState ('')
  33. const previousURLRef = useRef ('')
  34. const thumbnailPreviewRef = useRef ('')
  35. const handleSubmit = async () => {
  36. const formData = new FormData
  37. formData.append ('title', title)
  38. formData.append ('url', url)
  39. formData.append ('tags', tags)
  40. formData.append ('parent_post_ids', parentPostIds)
  41. if (thumbnailFile)
  42. formData.append ('thumbnail', thumbnailFile)
  43. if (originalCreatedFrom)
  44. formData.append ('original_created_from', originalCreatedFrom)
  45. if (originalCreatedBefore)
  46. formData.append ('original_created_before', originalCreatedBefore)
  47. try
  48. {
  49. await apiPost ('/posts', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
  50. toast ({ title: '投稿成功!' })
  51. navigate ('/posts')
  52. }
  53. catch
  54. {
  55. toast ({ title: '投稿失敗', description: '入力を確認してください。' })
  56. }
  57. }
  58. const handleURLBlur = () => {
  59. if (!(url) || url === previousURLRef.current)
  60. return
  61. if (titleAutoFlg)
  62. fetchTitle ()
  63. if (thumbnailAutoFlg)
  64. fetchThumbnail ()
  65. previousURLRef.current = url
  66. }
  67. const fetchTitle = useCallback (async () => {
  68. setTitle ('')
  69. setTitleLoading (true)
  70. const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } })
  71. setTitle (data.title || '')
  72. setTitleLoading (false)
  73. }, [url])
  74. const fetchThumbnail = useCallback (async () => {
  75. setThumbnailPreview ('')
  76. setThumbnailFile (null)
  77. setThumbnailLoading (true)
  78. if (thumbnailPreviewRef.current)
  79. URL.revokeObjectURL (thumbnailPreviewRef.current)
  80. const data = await apiGet<Blob> ('/preview/thumbnail',
  81. { params: { url }, responseType: 'blob' })
  82. const imageURL = URL.createObjectURL (data)
  83. setThumbnailPreview (imageURL)
  84. setThumbnailFile (new File ([data],
  85. 'thumbnail.png',
  86. { type: data.type || 'image/png' }))
  87. setThumbnailLoading (false)
  88. }, [url])
  89. useEffect (() => {
  90. thumbnailPreviewRef.current = thumbnailPreview
  91. }, [thumbnailPreview])
  92. useEffect (() => {
  93. if (titleAutoFlg && url)
  94. fetchTitle ()
  95. }, [fetchTitle, titleAutoFlg, url])
  96. useEffect (() => {
  97. if (thumbnailAutoFlg && url)
  98. fetchThumbnail ()
  99. }, [fetchThumbnail, thumbnailAutoFlg, url])
  100. if (!(editable))
  101. return <Forbidden/>
  102. return (
  103. <MainArea>
  104. <Helmet>
  105. <title>{`広場に投稿を追加 | ${ SITE_TITLE }`}</title>
  106. </Helmet>
  107. <Form>
  108. <PageTitle>広場に投稿を追加する</PageTitle>
  109. {/* URL */}
  110. <div>
  111. <Label>URL</Label>
  112. <input type="url"
  113. placeholder="例:https://www.nicovideo.jp/watch/..."
  114. value={url}
  115. onChange={e => setURL (e.target.value)}
  116. className="w-full border p-2 rounded"
  117. onBlur={handleURLBlur}/>
  118. </div>
  119. {/* タイトル */}
  120. <div>
  121. <Label checkBox={{
  122. label: '自動',
  123. checked: titleAutoFlg,
  124. onChange: ev => setTitleAutoFlg (ev.target.checked)}}>
  125. タイトル
  126. </Label>
  127. <input type="text"
  128. className="w-full border rounded p-2"
  129. value={title}
  130. placeholder={titleLoading ? 'Loading...' : ''}
  131. onChange={ev => setTitle (ev.target.value)}
  132. disabled={titleAutoFlg}/>
  133. </div>
  134. {/* サムネール */}
  135. <div>
  136. <Label checkBox={{
  137. label: '自動',
  138. checked: thumbnailAutoFlg,
  139. onChange: ev => setThumbnailAutoFlg (ev.target.checked)}}>
  140. サムネール
  141. </Label>
  142. {thumbnailAutoFlg
  143. ? (thumbnailLoading
  144. ? <p className="text-gray-500 text-sm">Loading...</p>
  145. : !(thumbnailPreview) && (
  146. <p className="text-gray-500 text-sm">
  147. URL から自動取得されます。
  148. </p>))
  149. : (
  150. <input type="file"
  151. accept="image/*"
  152. onChange={e => {
  153. const file = e.target.files?.[0]
  154. if (file)
  155. {
  156. setThumbnailFile (file)
  157. setThumbnailPreview (URL.createObjectURL (file))
  158. }
  159. }}/>)}
  160. {thumbnailPreview && (
  161. <img src={thumbnailPreview}
  162. alt="preview"
  163. className="mt-2 max-h-48 rounded border"/>)}
  164. </div>
  165. {/* 親投稿 */}
  166. <div>
  167. <Label>親投稿</Label>
  168. <input
  169. type="text"
  170. value={parentPostIds}
  171. onChange={e => setParentPostIds (e.target.value)}
  172. className="w-full border p-2 rounded"/>
  173. </div>
  174. {/* タグ */}
  175. <PostFormTagsArea tags={tags} setTags={setTags}/>
  176. {/* オリジナルの作成日時 */}
  177. <PostOriginalCreatedTimeField
  178. originalCreatedFrom={originalCreatedFrom}
  179. setOriginalCreatedFrom={setOriginalCreatedFrom}
  180. originalCreatedBefore={originalCreatedBefore}
  181. setOriginalCreatedBefore={setOriginalCreatedBefore}/>
  182. {/* 送信 */}
  183. <Button onClick={handleSubmit}
  184. className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
  185. disabled={titleLoading || thumbnailLoading}>
  186. 追加
  187. </Button>
  188. </Form>
  189. </MainArea>)
  190. }
  191. export default PostNewPage