276 行
8.7 KiB
TypeScript
276 行
8.7 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
|
|
import { useLocale } from '@/lib/useLocale'
|
|
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
|
import FieldError from '@/components/common/FieldError'
|
|
import PostDurationField from '@/components/posts/PostDurationField'
|
|
import PostTagsField from '@/components/posts/PostTagsField'
|
|
import PostTextField from '@/components/posts/PostTextField'
|
|
import { Button } from '@/components/ui/button'
|
|
import { toast } from '@/components/ui/use-toast'
|
|
import { isApiError } from '@/lib/api'
|
|
import useDialogue from '@/lib/dialogues/useDialogue'
|
|
import { updatePost } from '@/lib/posts'
|
|
import { msToTime } from '@/lib/utils'
|
|
import { useValidationErrors } from '@/lib/useValidationErrors'
|
|
import { useUITranslation } from '@/lib/useUITranslation'
|
|
|
|
import type { FC, FormEvent, ReactNode } from 'react'
|
|
|
|
import type { Post, TagWithSections } from '@/types'
|
|
|
|
type PostFormField =
|
|
'parentPostIds' | 'tags' | 'videoMs'
|
|
| 'originalCreatedAt' | 'originalCreatedFrom' | 'originalCreatedBefore'
|
|
|
|
type PostEditData = Omit<Parameters<typeof updatePost>[0], 'id'>
|
|
|
|
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
|
|
[...new Set (values.flatMap (value => value ?? []))]
|
|
|
|
const videoMsToDurationValue = (videoMs: number | null): string =>
|
|
videoMs == null ? '' : String (videoMs / 1_000)
|
|
|
|
|
|
const tagsToStr = (tags: TagWithSections[]): string => {
|
|
const result: Omit<TagWithSections, 'children'>[] = []
|
|
|
|
const walk = (tag: TagWithSections) => {
|
|
const { children, ...rest } = tag
|
|
result.push (rest)
|
|
children.forEach (walk)
|
|
}
|
|
|
|
tags.filter (t => t.category !== 'nico').forEach (walk)
|
|
|
|
return [...(new Set (result.map (t =>
|
|
`${ t.name }${ t.sections
|
|
.map (s => `[${ msToTime (s.beginMs) }-${ s.endMs == null ? '' : msToTime (s.endMs) }]`)
|
|
.join ('') }`)))].join (' ')
|
|
}
|
|
|
|
|
|
type Props = { post: Post
|
|
onSave: (newPost: Post) => void }
|
|
|
|
|
|
const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|
const locale = useLocale ()
|
|
const { label } = useUITranslation ('posts')
|
|
const [disabled, setDisabled] = useState (false)
|
|
const [duration, setDuration] = useState<string> (videoMsToDurationValue (post.videoMs))
|
|
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
|
useValidationErrors<PostFormField> ()
|
|
const [originalCreatedBefore, setOriginalCreatedBefore] =
|
|
useState<string | null> (post.originalCreatedBefore)
|
|
const [originalCreatedFrom, setOriginalCreatedFrom] =
|
|
useState<string | null> (post.originalCreatedFrom)
|
|
const [parentPostIds, setParentPostIds] =
|
|
useState ((post.parentPosts ?? []).map (p => p.id).join (' '))
|
|
const [tags, setTags] = useState<string> (() => tagsToStr (post.tags))
|
|
const [title, setTitle] = useState (post.title)
|
|
|
|
const videoFlg =
|
|
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
|
|
[tags])
|
|
|
|
const dialogue = useDialogue ()
|
|
|
|
const update = async (
|
|
postData: Parameters<typeof updatePost>[0],
|
|
options: Parameters<typeof updatePost>[1],
|
|
) => {
|
|
clearValidationErrors ()
|
|
|
|
try
|
|
{
|
|
const data = await updatePost (postData, options, locale)
|
|
onSave ({ ...post,
|
|
versionNo: data.versionNo,
|
|
title: data.title,
|
|
videoMs: data.videoMs,
|
|
tags: data.tags,
|
|
parentPosts: data.parentPosts,
|
|
childPosts: data.childPosts,
|
|
siblingPosts: data.siblingPosts,
|
|
originalCreatedFrom: data.originalCreatedFrom,
|
|
originalCreatedBefore: data.originalCreatedBefore } as Post)
|
|
toast ({ description: label ('shared:messages.updated') })
|
|
}
|
|
catch (e)
|
|
{
|
|
const response = isApiError<{ mergeable?: boolean }> (e) ? e.response : undefined
|
|
|
|
if (response?.status !== 409)
|
|
{
|
|
if (applyValidationError (e))
|
|
{
|
|
toast ({ description: label ('edit.validationFailed') })
|
|
return
|
|
}
|
|
|
|
toast ({ title: label ('shared:messages.failed'),
|
|
description: label ('shared:messages.checkInput') })
|
|
|
|
return
|
|
}
|
|
|
|
const action = await dialogue.choice ({
|
|
title: label ('edit.conflictTitle'),
|
|
description: (
|
|
<div>
|
|
<p>{label ('edit.conflictDescription')}</p>
|
|
<p>{label ('edit.conflictQuestion')}</p>
|
|
</div>),
|
|
cancelText: label ('shared:actions.cancel'),
|
|
closeLabel: label ('settings:actions.close'),
|
|
choices: [...(response?.data?.mergeable
|
|
? [{ value: 'merge', label: label ('edit.merge') }] : []),
|
|
{ value: 'overwrite', label: label ('edit.overwrite'), variant: 'danger' }] })
|
|
|
|
if (action === 'merge')
|
|
{
|
|
// TODO: 差分 UI
|
|
await update ({ id: post.id, title, tags, parentPostIds,
|
|
duration: videoFlg ? duration : null,
|
|
originalCreatedFrom, originalCreatedBefore },
|
|
{ baseVersionNo: post.versionNo, merge: true })
|
|
return
|
|
}
|
|
|
|
if (action === 'overwrite')
|
|
{
|
|
await update ({ id: post.id, title, tags, parentPostIds,
|
|
duration: videoFlg ? duration : null,
|
|
originalCreatedFrom, originalCreatedBefore },
|
|
{ baseVersionNo: post.versionNo, force: true })
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleSubmit = async (e: FormEvent) => {
|
|
e.preventDefault ()
|
|
|
|
if (disabled) return
|
|
|
|
setDisabled (true)
|
|
try
|
|
{
|
|
const current: PostEditData = {
|
|
title, tags, parentPostIds,
|
|
duration: videoFlg ? duration : null,
|
|
originalCreatedFrom, originalCreatedBefore }
|
|
const fields: {
|
|
[Key in keyof PostEditData]-?: {
|
|
name: ReactNode
|
|
prev: PostEditData[Key] } } = {
|
|
title: { name: label ('shared:labels.title'), prev: post.title },
|
|
parentPostIds: {
|
|
name: label ('shared:labels.parentPosts'),
|
|
prev: (post.parentPosts ?? []).map (parent => parent.id).join (' ') },
|
|
tags: { name: label ('tags:title'), prev: tagsToStr (post.tags) },
|
|
duration: {
|
|
name: label ('shared:labels.duration'),
|
|
prev: videoMsToDurationValue (post.videoMs) },
|
|
originalCreatedFrom: {
|
|
name: <>{label ('shared:labels.originalCreatedAt')}{' '}
|
|
{label ('shared:labels.since')}</>,
|
|
prev: post.originalCreatedFrom },
|
|
originalCreatedBefore: {
|
|
name: <>{label ('shared:labels.originalCreatedAt')}{' '}
|
|
{label ('shared:labels.before')}</>,
|
|
prev: post.originalCreatedBefore } }
|
|
const differences = (Object.keys (fields) as (keyof PostEditData)[])
|
|
.map (key => ({ key, name: fields[key].name,
|
|
prev: fields[key].prev ?? '', current: current[key] ?? '' }))
|
|
.filter (row => row.prev !== row.current)
|
|
|
|
if (!(await dialogue.confirm ({
|
|
title: label ('shared:actions.diff'),
|
|
confirmText: label ('shared:actions.update'),
|
|
description: (
|
|
<dl className="max-h-[60dvh] overflow-y-auto space-y-3 text-left">
|
|
{differences.map (row => (
|
|
<div key={row.key}>
|
|
<dt className="font-semibold">{row.name}</dt>
|
|
<dd className="whitespace-pre-wrap break-words">
|
|
{row.prev && (
|
|
<del className="block text-red-600 dark:text-red-400">
|
|
{row.prev}
|
|
</del>)}
|
|
{row.current && (
|
|
<ins className="block text-green-700 dark:text-green-400">
|
|
{row.current}
|
|
</ins>)}
|
|
</dd>
|
|
</div>))}
|
|
</dl>) })))
|
|
return
|
|
|
|
await update ({ id: post.id, ...current },
|
|
{ baseVersionNo: post.versionNo })
|
|
}
|
|
finally
|
|
{
|
|
setDisabled (false)
|
|
}
|
|
}
|
|
|
|
useEffect (() => {
|
|
setTags (tagsToStr (post.tags))
|
|
setDuration (videoMsToDurationValue (post.videoMs))
|
|
}, [post])
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4">
|
|
<FieldError messages={baseErrors}/>
|
|
|
|
<PostTextField
|
|
label={label ('shared:labels.title')}
|
|
value={title ?? ''}
|
|
disabled={disabled}
|
|
onChange={setTitle}/>
|
|
|
|
<PostTextField
|
|
label={label ('shared:labels.parentPosts')}
|
|
value={parentPostIds}
|
|
disabled={disabled}
|
|
errors={fieldErrors.parentPostIds}
|
|
onChange={setParentPostIds}/>
|
|
|
|
<PostTagsField
|
|
disabled={disabled}
|
|
tags={tags}
|
|
setTags={setTags}
|
|
errors={fieldErrors.tags}/>
|
|
|
|
{/* オリジナルの作成日時 */}
|
|
<PostOriginalCreatedTimeField
|
|
disabled={disabled}
|
|
originalCreatedFrom={originalCreatedFrom}
|
|
setOriginalCreatedFrom={setOriginalCreatedFrom}
|
|
originalCreatedBefore={originalCreatedBefore}
|
|
setOriginalCreatedBefore={setOriginalCreatedBefore}
|
|
errors={groupedMessages (
|
|
fieldErrors.originalCreatedAt,
|
|
fieldErrors.originalCreatedFrom,
|
|
fieldErrors.originalCreatedBefore)}/>
|
|
|
|
{videoFlg && (
|
|
<PostDurationField
|
|
value={duration}
|
|
disabled={disabled}
|
|
errors={fieldErrors.videoMs}
|
|
onChange={setDuration}/>)}
|
|
|
|
{/* 送信 */}
|
|
<Button type="submit" disabled={disabled}>
|
|
{label ('shared:actions.update')}
|
|
</Button>
|
|
</form>)
|
|
}
|
|
|
|
|
|
export default PostEditForm
|