このコミットが含まれているのは:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import PostFormTagsArea from '@/components/PostFormTagsArea'
|
||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||
@@ -17,7 +17,10 @@ import type { FC, FormEvent } from 'react'
|
||||
import type { Post, TagWithSections } from '@/types'
|
||||
|
||||
type PostFormField =
|
||||
'parentPostIds' | 'tags' | 'originalCreatedAt'
|
||||
'parentPostIds' | 'tags' | 'videoMs' | 'originalCreatedAt'
|
||||
|
||||
const videoMsToDurationValue = (videoMs: number | null): string =>
|
||||
videoMs == null ? '' : String (videoMs / 1_000)
|
||||
|
||||
|
||||
const tagsToStr = (tags: TagWithSections[]): string => {
|
||||
@@ -44,6 +47,7 @@ type Props = { post: Post
|
||||
|
||||
const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
const [disabled, setDisabled] = useState (false)
|
||||
const [duration, setDuration] = useState<string> (videoMsToDurationValue (post.videoMs))
|
||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||||
useValidationErrors<PostFormField> ()
|
||||
const [originalCreatedBefore, setOriginalCreatedBefore] =
|
||||
@@ -55,6 +59,10 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
const [tags, setTags] = useState<string> ('')
|
||||
const [title, setTitle] = useState (post.title)
|
||||
|
||||
const videoFlg =
|
||||
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
|
||||
[tags])
|
||||
|
||||
const dialogue = useDialogue ()
|
||||
|
||||
const update = async (...args: Parameters<typeof updatePost>) => {
|
||||
@@ -66,6 +74,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
onSave ({ ...post,
|
||||
versionNo: data.versionNo,
|
||||
title: data.title,
|
||||
videoMs: data.videoMs,
|
||||
tags: data.tags,
|
||||
parentPosts: data.parentPosts,
|
||||
childPosts: data.childPosts,
|
||||
@@ -105,6 +114,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
{
|
||||
// TODO: 差分 UI
|
||||
await update ({ id: post.id, title, tags, parentPostIds,
|
||||
duration: videoFlg ? duration : null,
|
||||
originalCreatedFrom, originalCreatedBefore },
|
||||
{ baseVersionNo: post.versionNo, merge: true })
|
||||
return
|
||||
@@ -113,6 +123,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
if (action === 'overwrite')
|
||||
{
|
||||
await update ({ id: post.id, title, tags, parentPostIds,
|
||||
duration: videoFlg ? duration : null,
|
||||
originalCreatedFrom, originalCreatedBefore },
|
||||
{ baseVersionNo: post.versionNo, force: true })
|
||||
return
|
||||
@@ -127,6 +138,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
try
|
||||
{
|
||||
await update ({ id: post.id, title, tags, parentPostIds,
|
||||
duration: videoFlg ? duration : null,
|
||||
originalCreatedFrom, originalCreatedBefore },
|
||||
{ baseVersionNo: post.versionNo })
|
||||
}
|
||||
@@ -138,8 +150,14 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
|
||||
useEffect (() => {
|
||||
setTags (tagsToStr (post.tags))
|
||||
setDuration (videoMsToDurationValue (post.videoMs))
|
||||
}, [post])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(videoFlg))
|
||||
setDuration ('')
|
||||
}, [videoFlg])
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4">
|
||||
<FieldError messages={baseErrors}/>
|
||||
@@ -152,7 +170,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
disabled={disabled}
|
||||
className={inputClass (invalid)}
|
||||
value={title ?? ''}
|
||||
onChange={ev => setTitle (ev.target.value)}/>)}
|
||||
onChange={e => setTitle (e.target.value)}/>)}
|
||||
</FormField>
|
||||
|
||||
{/* 親投稿 */}
|
||||
@@ -184,6 +202,20 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
setOriginalCreatedBefore={setOriginalCreatedBefore}
|
||||
errors={fieldErrors.originalCreatedAt}/>
|
||||
|
||||
{/* 動画時間 */}
|
||||
{videoFlg && (
|
||||
<FormField label="動画時間" messages={fieldErrors.videoMs}>
|
||||
{({ invalid }) => (
|
||||
<input
|
||||
type="number"
|
||||
min="0.001"
|
||||
step="0.001"
|
||||
disabled={disabled}
|
||||
className={inputClass (invalid)}
|
||||
value={duration}
|
||||
onChange={e => setDuration (e.target.value)}/>)}
|
||||
</FormField>)}
|
||||
|
||||
{/* 送信 */}
|
||||
<Button type="submit" disabled={disabled}>
|
||||
更新
|
||||
@@ -191,4 +223,5 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
</form>)
|
||||
}
|
||||
|
||||
|
||||
export default PostEditForm
|
||||
|
||||
@@ -46,6 +46,7 @@ export const updatePost = async (
|
||||
post: { id: number
|
||||
title: string | null
|
||||
tags: string
|
||||
duration: string | null
|
||||
parentPostIds: string
|
||||
originalCreatedFrom: string | null
|
||||
originalCreatedBefore: string | null },
|
||||
@@ -58,6 +59,7 @@ export const updatePost = async (
|
||||
`/posts/${ post.id }`,
|
||||
{ title: post.title,
|
||||
tags: post.tags,
|
||||
duration: post.duration,
|
||||
parent_post_ids: post.parentPostIds,
|
||||
original_created_from: post.originalCreatedFrom,
|
||||
original_created_before: post.originalCreatedBefore },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { cn, originalCreatedAtString, toDate } from '@/lib/utils'
|
||||
import { cn, msToTime, originalCreatedAtString, toDate } from '@/lib/utils'
|
||||
|
||||
describe ('utils', () => {
|
||||
it ('converts strings to dates and leaves date instances intact', () => {
|
||||
@@ -26,3 +26,14 @@ describe ('utils', () => {
|
||||
).toContain ('時刻不詳')
|
||||
})
|
||||
})
|
||||
|
||||
describe ('msToTime', () => {
|
||||
it ('keeps milliseconds when present', () => {
|
||||
expect (msToTime (60_500)).toBe ('1:00.500')
|
||||
expect (msToTime (60_001)).toBe ('1:00.001')
|
||||
})
|
||||
|
||||
it ('omits milliseconds when they are zero', () => {
|
||||
expect (msToTime (60_000)).toBe ('1:00')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -78,10 +78,14 @@ export const msToTime = (ms: number): string => {
|
||||
const s = String (totalS % 60)
|
||||
const min = String (Math.trunc (totalS / 60) % 60)
|
||||
const h = Math.trunc (totalS / 3_600)
|
||||
const remainderMs = ms % 1_000
|
||||
|
||||
return (h > 0
|
||||
? `${ h }:${ min.padStart (2, '0') }:${ s.padStart (2, '0') }`
|
||||
: `${ min }:${ s.padStart (2, '0') }`)
|
||||
const base =
|
||||
(h > 0
|
||||
? `${ h }:${ min.padStart (2, '0') }:${ s.padStart (2, '0') }`
|
||||
: `${ min }:${ s.padStart (2, '0') }`)
|
||||
|
||||
return remainderMs > 0 ? `${ base }.${ remainderMs.toString ().padStart (3, '0') }` : base
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { SITE_TITLE } from '@/config'
|
||||
import { fetchPostChanges, updatePost } from '@/lib/posts'
|
||||
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
|
||||
import { fetchTag } from '@/lib/tags'
|
||||
import { cn, dateString, originalCreatedAtString } from '@/lib/utils'
|
||||
import { cn, dateString, msToTime, originalCreatedAtString } from '@/lib/utils'
|
||||
|
||||
import type { FC, MouseEvent } from 'react'
|
||||
|
||||
@@ -91,9 +91,13 @@ const PostHistoryPage: FC = () => {
|
||||
.filter (p => p.type !== 'removed')
|
||||
.map (p => p.id)
|
||||
.join (' ')
|
||||
const duration =
|
||||
change.videoMs.current == null
|
||||
? null
|
||||
: msToTime (change.videoMs.current)
|
||||
const originalCreatedFrom = change.originalCreatedFrom.current
|
||||
const originalCreatedBefore = change.originalCreatedBefore.current
|
||||
await updatePost ({ id, title, tags, parentPostIds,
|
||||
await updatePost ({ id, title, tags, duration, parentPostIds,
|
||||
originalCreatedFrom, originalCreatedBefore },
|
||||
{ force: true })
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
@@ -25,7 +25,7 @@ import type { User } from '@/types'
|
||||
type Props = { user: User | null }
|
||||
|
||||
type PostFormField =
|
||||
'url' | 'title' | 'tags' | 'parentPostIds' | 'originalCreatedAt' | 'thumbnail'
|
||||
'url' | 'title' | 'tags' | 'parentPostIds' | 'videoMs' | 'originalCreatedAt' | 'thumbnail'
|
||||
|
||||
|
||||
const PostNewPage: FC<Props> = ({ user }) => {
|
||||
@@ -40,6 +40,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
||||
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
|
||||
const [parentPostIds, setParentPostIds] = useState ('')
|
||||
const [tags, setTags] = useState ('')
|
||||
const [duration, setDuration] = useState ('')
|
||||
const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true)
|
||||
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
|
||||
const [thumbnailLoading, setThumbnailLoading] = useState (false)
|
||||
@@ -51,6 +52,9 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
||||
|
||||
const previousURLRef = useRef ('')
|
||||
const thumbnailPreviewRef = useRef ('')
|
||||
const videoFlg =
|
||||
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
|
||||
[tags])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
clearValidationErrors ()
|
||||
@@ -60,6 +64,8 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
||||
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)
|
||||
@@ -129,6 +135,11 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
||||
fetchThumbnail ()
|
||||
}, [fetchThumbnail, thumbnailAutoFlg, url])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(videoFlg))
|
||||
setDuration ('')
|
||||
}, [videoFlg])
|
||||
|
||||
if (!(editable))
|
||||
return <Forbidden/>
|
||||
|
||||
@@ -233,6 +244,20 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
||||
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"
|
||||
|
||||
@@ -35,6 +35,7 @@ export const buildPost = (overrides: Partial<Post> = {}): Post => ({
|
||||
title: 'テスト投稿',
|
||||
thumbnail: 'https://example.com/thumb.jpg',
|
||||
thumbnailBase: null,
|
||||
videoMs: null,
|
||||
tags: [buildTag ()],
|
||||
parentPosts: [],
|
||||
childPosts: [],
|
||||
|
||||
@@ -140,6 +140,7 @@ export type Post = {
|
||||
title: string | null
|
||||
thumbnail: string | null
|
||||
thumbnailBase: string | null
|
||||
videoMs: number | null
|
||||
postSimilarityEdges?: {
|
||||
targetPostId: number
|
||||
cos: number
|
||||
@@ -172,6 +173,7 @@ export type PostVersion = {
|
||||
url: { current: string; prev: string | null }
|
||||
thumbnail: { current: string | null; prev: string | null }
|
||||
thumbnailBase: { current: string | null; prev: string | null }
|
||||
videoMs: { current: number | null; prev: number | null }
|
||||
tags: { name: string
|
||||
type: 'context' | 'added' | 'removed' }[]
|
||||
parentPosts: { id: number
|
||||
|
||||
新しい課題から参照
ユーザをブロックする