270 行
8.0 KiB
TypeScript
270 行
8.0 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { Helmet } from 'react-helmet-async'
|
||
import { useNavigate } from 'react-router-dom'
|
||
|
||
import PostFormTagsArea from '@/components/PostFormTagsArea'
|
||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||
import FieldError from '@/components/common/FieldError'
|
||
import Form from '@/components/common/Form'
|
||
import FormField from '@/components/common/FormField'
|
||
import PageTitle from '@/components/common/PageTitle'
|
||
import MainArea from '@/components/layout/MainArea'
|
||
import { Button } from '@/components/ui/button'
|
||
import { toast } from '@/components/ui/use-toast'
|
||
import { SITE_TITLE } from '@/config'
|
||
import { apiGet, apiPost } from '@/lib/api'
|
||
import { canEditContent } from '@/lib/users'
|
||
import { inputClass } from '@/lib/utils'
|
||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||
import Forbidden from '@/pages/Forbidden'
|
||
|
||
import type { FC } from 'react'
|
||
|
||
import type { User } from '@/types'
|
||
|
||
type Props = { user: User | null }
|
||
|
||
type PostFormField =
|
||
'url'
|
||
| 'title'
|
||
| 'tags'
|
||
| 'parentPostIds'
|
||
| 'videoMs'
|
||
| 'originalCreatedAt'
|
||
| 'thumbnail'
|
||
|
||
|
||
const PostNewPage: FC<Props> = ({ user }) => {
|
||
const editable = canEditContent (user)
|
||
|
||
const navigate = useNavigate ()
|
||
|
||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||
useValidationErrors<PostFormField> ()
|
||
|
||
const [originalCreatedBefore, setOriginalCreatedBefore] =
|
||
useState<string | null> (null)
|
||
const [originalCreatedFrom, setOriginalCreatedFrom] =
|
||
useState<string | null> (null)
|
||
const [parentPostIds, setParentPostIds] = useState ('')
|
||
const [tags, setTags] = useState ('')
|
||
const [duration, setDuration] = useState ('')
|
||
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
|
||
const [thumbnailLoading, setThumbnailLoading] = useState (false)
|
||
const [thumbnailPreview, setThumbnailPreview] = useState<string> ('')
|
||
const [title, setTitle] = useState ('')
|
||
const [titleLoading, setTitleLoading] = useState (false)
|
||
const [url, setURL] = useState ('')
|
||
|
||
const thumbnailPreviewRef = useRef ('')
|
||
const videoFlg =
|
||
useMemo (() =>
|
||
tags.split (/\s+/).some (
|
||
tag => tag.replace (/\[.*\]$/, '') === '動画'),
|
||
[tags])
|
||
|
||
const handleSubmit = async () => {
|
||
clearValidationErrors ()
|
||
|
||
const formData = new FormData
|
||
formData.append ('title', title)
|
||
formData.append ('url', url)
|
||
formData.append ('tags', tags)
|
||
formData.append ('parent_post_ids', parentPostIds)
|
||
if (videoFlg && duration !== '')
|
||
formData.append ('duration', duration)
|
||
if (thumbnailFile)
|
||
formData.append ('thumbnail', thumbnailFile)
|
||
if (originalCreatedFrom)
|
||
formData.append ('original_created_from', originalCreatedFrom)
|
||
if (originalCreatedBefore)
|
||
formData.append ('original_created_before', originalCreatedBefore)
|
||
|
||
try
|
||
{
|
||
await apiPost ('/posts', formData,
|
||
{ headers: { 'Content-Type': 'multipart/form-data' } })
|
||
toast ({ title: '投稿成功!' })
|
||
navigate ('/posts')
|
||
}
|
||
catch (e)
|
||
{
|
||
applyValidationError (e)
|
||
toast ({ title: '投稿失敗', description: '入力を確認してください.' })
|
||
}
|
||
}
|
||
|
||
const fetchTitle = useCallback (async () => {
|
||
setTitleLoading (true)
|
||
try
|
||
{
|
||
const data = await apiGet<{ title: string }> ('/preview/title',
|
||
{ params: { url } })
|
||
setTitle (data.title || '')
|
||
}
|
||
finally
|
||
{
|
||
setTitleLoading (false)
|
||
}
|
||
}, [url])
|
||
|
||
const fetchThumbnail = useCallback (async () => {
|
||
setThumbnailPreview ('')
|
||
setThumbnailFile (null)
|
||
setThumbnailLoading (true)
|
||
if (thumbnailPreviewRef.current)
|
||
URL.revokeObjectURL (thumbnailPreviewRef.current)
|
||
try
|
||
{
|
||
const data = await apiGet<Blob> ('/preview/thumbnail',
|
||
{ params: { url },
|
||
responseType: 'blob' })
|
||
const imageURL = URL.createObjectURL (data)
|
||
setThumbnailPreview (imageURL)
|
||
setThumbnailFile (new File ([data],
|
||
'thumbnail.png',
|
||
{ type: data.type || 'image/png' }))
|
||
}
|
||
finally
|
||
{
|
||
setThumbnailLoading (false)
|
||
}
|
||
}, [url])
|
||
|
||
useEffect (() => {
|
||
thumbnailPreviewRef.current = thumbnailPreview
|
||
}, [thumbnailPreview])
|
||
|
||
if (!(editable))
|
||
return <Forbidden/>
|
||
|
||
return (
|
||
<MainArea>
|
||
<Helmet>
|
||
<title>{`広場に投稿を追加 | ${ SITE_TITLE }`}</title>
|
||
</Helmet>
|
||
<Form>
|
||
<PageTitle>広場に投稿を追加する</PageTitle>
|
||
<FieldError messages={baseErrors}/>
|
||
|
||
{/* URL */}
|
||
<FormField label="URL" messages={fieldErrors.url}>
|
||
{({ describedBy, invalid }) => (
|
||
<input type="url"
|
||
placeholder="例:https://www.nicovideo.jp/watch/..."
|
||
value={url}
|
||
onChange={e => setURL (e.target.value)}
|
||
aria-describedby={describedBy}
|
||
aria-invalid={invalid}
|
||
className={inputClass (invalid)}/>)}
|
||
</FormField>
|
||
|
||
{/* タイトル */}
|
||
<FormField label="タイトル" messages={fieldErrors.title}>
|
||
{({ describedBy, invalid }) => (
|
||
<div className="space-y-2">
|
||
<input type="text"
|
||
aria-describedby={describedBy}
|
||
aria-invalid={invalid}
|
||
className={inputClass (invalid)}
|
||
value={title}
|
||
placeholder={titleLoading ? 'Loading...' : ''}
|
||
onChange={ev => setTitle (ev.target.value)}
|
||
disabled={titleLoading}/>
|
||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||
<span>必要なタイミングで URL から取得できます.</span>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={fetchTitle}
|
||
disabled={!(url) || titleLoading}>
|
||
取得
|
||
</Button>
|
||
</div>
|
||
</div>)}
|
||
</FormField>
|
||
|
||
{/* サムネール */}
|
||
<FormField label="サムネール" messages={fieldErrors.thumbnail}>
|
||
{({ describedBy, invalid }) => (
|
||
<>
|
||
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
|
||
<span>必要なタイミングで URL から取得できます.</span>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={fetchThumbnail}
|
||
disabled={!(url) || thumbnailLoading}>
|
||
取得
|
||
</Button>
|
||
</div>
|
||
{thumbnailLoading && (
|
||
<p className="text-gray-500 text-sm">Loading...</p>)}
|
||
<input type="file"
|
||
accept="image/*"
|
||
aria-describedby={describedBy}
|
||
aria-invalid={invalid}
|
||
onChange={e => {
|
||
const file = e.target.files?.[0]
|
||
if (file)
|
||
{
|
||
setThumbnailFile (file)
|
||
setThumbnailPreview (URL.createObjectURL (file))
|
||
}
|
||
}}/>
|
||
{thumbnailPreview && (
|
||
<img src={thumbnailPreview}
|
||
alt="preview"
|
||
className="mt-2 max-h-48 rounded border"/>)}
|
||
</>)}
|
||
</FormField>
|
||
|
||
{/* 親投稿 */}
|
||
<FormField label="親投稿" messages={fieldErrors.parentPostIds}>
|
||
{({ describedBy, invalid }) => (
|
||
<input
|
||
type="text"
|
||
value={parentPostIds}
|
||
onChange={e => setParentPostIds (e.target.value)}
|
||
aria-describedby={describedBy}
|
||
aria-invalid={invalid}
|
||
className={inputClass (invalid)}/>)}
|
||
</FormField>
|
||
|
||
{/* タグ */}
|
||
<PostFormTagsArea tags={tags} setTags={setTags} errors={fieldErrors.tags}/>
|
||
|
||
{/* オリジナルの作成日時 */}
|
||
<PostOriginalCreatedTimeField
|
||
originalCreatedFrom={originalCreatedFrom}
|
||
setOriginalCreatedFrom={setOriginalCreatedFrom}
|
||
originalCreatedBefore={originalCreatedBefore}
|
||
setOriginalCreatedBefore={setOriginalCreatedBefore}
|
||
errors={fieldErrors.originalCreatedAt}/>
|
||
|
||
{/* 動画時間 */}
|
||
{(videoFlg &&
|
||
<FormField label="動画時間" messages={fieldErrors.videoMs}>
|
||
{({ invalid }) => (
|
||
<input
|
||
type="number"
|
||
min="0.001"
|
||
step="0.001"
|
||
value={duration}
|
||
onChange={e => setDuration (e.target.value)}
|
||
aria-invalid={invalid}
|
||
className={inputClass (invalid)}/>)}
|
||
</FormField>)}
|
||
|
||
{/* 送信 */}
|
||
<Button onClick={handleSubmit}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
|
||
disabled={titleLoading || thumbnailLoading}>
|
||
追加
|
||
</Button>
|
||
</Form>
|
||
</MainArea>)
|
||
}
|
||
|
||
export default PostNewPage
|