ファイル
btrc-hub/frontend/src/pages/posts/PostNewPage.tsx
T
2026-07-12 12:23:53 +09:00

270 行
8.0 KiB
TypeScript
Raw Blame 履歴

このファイルには曖昧(ambiguous)なUnicode文字が含まれてゐます
このファイルには,他の文字と見間違える可能性があるUnicode文字が含まれてゐます. それが意図的なものと考えられる場合は,この警告を無視して構ゐません. それらの文字を表示するにはエスケープボタンを使用します.
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