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

199 lines
5.9 KiB

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