#353 を再開できなかったので. Reviewed-on: #380 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #380 でマージされました.
このコミットが含まれているのは:
@@ -66,4 +66,27 @@ describe ('PostEditForm', () => {
|
||||
expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 }))
|
||||
expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' })
|
||||
})
|
||||
|
||||
it ('preserves duration while the video tag is temporarily removed', () => {
|
||||
const post = buildPost ({
|
||||
videoMs: 180_500,
|
||||
tags: [
|
||||
buildTag ({ id: 1, name: '動画', category: 'general' }),
|
||||
buildTag ({ id: 2, name: 'general-tag', category: 'general' }),
|
||||
],
|
||||
})
|
||||
|
||||
render (<PostEditForm post={post} onSave={vi.fn ()}/>)
|
||||
|
||||
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
|
||||
|
||||
const tags = screen.getAllByRole ('textbox')[2]
|
||||
fireEvent.change (tags, { target: { value: 'general-tag' } })
|
||||
expect (screen.queryByRole ('spinbutton')).not.toBeInTheDocument ()
|
||||
|
||||
fireEvent.change (tags, {
|
||||
target: { value: '動画 general-tag' },
|
||||
})
|
||||
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
@@ -9,29 +9,35 @@ import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { isApiError } from '@/lib/api'
|
||||
import { updatePost } from '@/lib/posts'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import { inputClass, msToTime } from '@/lib/utils'
|
||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||
|
||||
import type { FC, FormEvent } from 'react'
|
||||
|
||||
import type { Post, Tag } from '@/types'
|
||||
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: Tag[]): string => {
|
||||
const result: Tag[] = []
|
||||
const tagsToStr = (tags: TagWithSections[]): string => {
|
||||
const result: Omit<TagWithSections, 'children'>[] = []
|
||||
|
||||
const walk = (tag: Tag) => {
|
||||
const walk = (tag: TagWithSections) => {
|
||||
const { children, ...rest } = tag
|
||||
result.push (rest)
|
||||
children?.forEach (walk)
|
||||
children.forEach (walk)
|
||||
}
|
||||
|
||||
tags.filter (t => t.category !== 'nico').forEach (walk)
|
||||
|
||||
return [...(new Set (result.map (t => t.name)))].join (' ')
|
||||
return [...(new Set (result.map (t =>
|
||||
`${ t.name }${ t.sections
|
||||
.map (s => `[${ msToTime (s.beginMs) }-${ s.endMs == null ? '' : msToTime (s.endMs) }]`)
|
||||
.join ('') }`)))].join (' ')
|
||||
}
|
||||
|
||||
|
||||
@@ -41,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] =
|
||||
@@ -52,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>) => {
|
||||
@@ -63,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,
|
||||
@@ -102,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
|
||||
@@ -110,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
|
||||
@@ -124,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 })
|
||||
}
|
||||
@@ -134,7 +149,8 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
setTags(tagsToStr (post.tags))
|
||||
setTags (tagsToStr (post.tags))
|
||||
setDuration (videoMsToDurationValue (post.videoMs))
|
||||
}, [post])
|
||||
|
||||
return (
|
||||
@@ -149,7 +165,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>
|
||||
|
||||
{/* 親投稿 */}
|
||||
@@ -181,6 +197,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}>
|
||||
更新
|
||||
@@ -188,4 +218,5 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
</form>)
|
||||
}
|
||||
|
||||
|
||||
export default PostEditForm
|
||||
|
||||
@@ -26,13 +26,13 @@ import { dateString, originalCreatedAtString } from '@/lib/utils'
|
||||
import type { DragEndEvent } from '@dnd-kit/core'
|
||||
import type { FC, MutableRefObject, ReactNode } from 'react'
|
||||
|
||||
import type { Category, Post, Tag } from '@/types'
|
||||
import type { Category, Post, TagWithSections } from '@/types'
|
||||
|
||||
type TagByCategory = { [key in Category]: Tag[] }
|
||||
type TagByCategory = { [key in Category]: TagWithSections[] }
|
||||
|
||||
|
||||
const renderTagTree = (
|
||||
tag: Tag,
|
||||
tag: TagWithSections,
|
||||
nestLevel: number,
|
||||
path: string,
|
||||
suppressClickRef: MutableRefObject<boolean>,
|
||||
@@ -62,8 +62,9 @@ const renderTagTree = (
|
||||
|
||||
|
||||
const isDescendant = (
|
||||
root: Tag,
|
||||
targetId: number): boolean => {
|
||||
root: TagWithSections,
|
||||
targetId: number,
|
||||
): boolean => {
|
||||
if (!(root.children))
|
||||
return false
|
||||
|
||||
@@ -81,8 +82,9 @@ const isDescendant = (
|
||||
|
||||
const findTag = (
|
||||
byCat: TagByCategory,
|
||||
id: number): Tag | undefined => {
|
||||
const walk = (nodes: Tag[]): Tag | undefined => {
|
||||
id: number,
|
||||
): TagWithSections | undefined => {
|
||||
const walk = (nodes: TagWithSections[]): TagWithSections | undefined => {
|
||||
for (const t of nodes)
|
||||
{
|
||||
if (t.id === id)
|
||||
@@ -163,7 +165,7 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
|
||||
}
|
||||
|
||||
for (const cat of Object.keys (tagsTmp) as (keyof typeof tagsTmp)[])
|
||||
tagsTmp[cat].sort ((tagA: Tag, tagB: Tag) => tagA.name < tagB.name ? -1 : 1)
|
||||
tagsTmp[cat].sort ((tagA: TagWithSections, tagB: TagWithSections) => tagA.name < tagB.name ? -1 : 1)
|
||||
|
||||
return tagsTmp
|
||||
}, [post])
|
||||
@@ -373,4 +375,4 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
|
||||
</SidebarComponent>)
|
||||
}
|
||||
|
||||
export default TagDetailSidebar
|
||||
export default TagDetailSidebar
|
||||
|
||||
@@ -70,6 +70,7 @@ describe ('posts API functions', () => {
|
||||
id: 5,
|
||||
title: 'new title',
|
||||
tags: 'tag',
|
||||
duration: null,
|
||||
parentPostIds: '1 2',
|
||||
originalCreatedFrom: null,
|
||||
originalCreatedBefore: '2026-01-02T00:00:00Z',
|
||||
@@ -82,6 +83,7 @@ describe ('posts API functions', () => {
|
||||
{
|
||||
title: 'new title',
|
||||
tags: 'tag',
|
||||
duration: null,
|
||||
parent_post_ids: '1 2',
|
||||
original_created_from: null,
|
||||
original_created_before: '2026-01-02T00:00:00Z',
|
||||
|
||||
@@ -44,6 +44,7 @@ export const updatePost = async (
|
||||
post: { id: number
|
||||
title: string | null
|
||||
tags: string
|
||||
duration: string | null
|
||||
parentPostIds: string
|
||||
originalCreatedFrom: string | null
|
||||
originalCreatedBefore: string | null },
|
||||
@@ -55,6 +56,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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -71,6 +71,22 @@ export const originalCreatedAtString = (
|
||||
}
|
||||
|
||||
|
||||
export const msToTime = (ms: number): string => {
|
||||
const totalS = Math.trunc (ms / 1_000)
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
export const inputClass = (invalid?: boolean, className?: string): string =>
|
||||
cn ('w-full rounded border p-2',
|
||||
(invalid
|
||||
|
||||
@@ -91,9 +91,13 @@ const PostHistoryPage: FC = () => {
|
||||
.filter (p => p.type !== 'removed')
|
||||
.map (p => p.id)
|
||||
.join (' ')
|
||||
const duration =
|
||||
change.videoMs.current == null
|
||||
? null
|
||||
: String (change.videoMs.current / 1_000)
|
||||
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 })
|
||||
|
||||
|
||||
@@ -62,6 +62,24 @@ describe ('PostNewPage', () => {
|
||||
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' })
|
||||
})
|
||||
|
||||
it ('preserves duration while the video tag is temporarily removed', () => {
|
||||
api.apiGet.mockResolvedValue ([])
|
||||
|
||||
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
||||
|
||||
const tags = screen.getAllByRole ('textbox')[3]
|
||||
fireEvent.change (tags, { target: { value: '動画' } })
|
||||
fireEvent.change (screen.getByRole ('spinbutton'), { target: { value: '180.5' } })
|
||||
|
||||
fireEvent.change (tags, { target: { value: 'general-tag' } })
|
||||
expect (screen.queryByRole ('spinbutton')).not.toBeInTheDocument ()
|
||||
|
||||
fireEvent.change (tags, {
|
||||
target: { value: '動画 general-tag' },
|
||||
})
|
||||
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
|
||||
})
|
||||
|
||||
it ('shows 422 validation errors for post fields', async () => {
|
||||
api.apiGet.mockResolvedValue ([])
|
||||
api.isApiError.mockReturnValue (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)
|
||||
@@ -233,6 +239,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"
|
||||
|
||||
@@ -88,6 +88,10 @@ const weights = buildTheatrePostSelectionWeights ({
|
||||
tags: [],
|
||||
}],
|
||||
})
|
||||
const watchingUsers = [
|
||||
{ id: 1, name: 'tester' },
|
||||
{ id: 2, name: 'another' },
|
||||
]
|
||||
|
||||
const renderPage = (user = buildUser ({ id: 1, role: 'member' })) =>
|
||||
renderWithProviders (
|
||||
@@ -136,11 +140,11 @@ const mockDefaultApi = () => {
|
||||
postId: currentPost.id,
|
||||
postStartedAt: '2026-01-02T03:04:05.000Z',
|
||||
postElapsedMs: 1_000,
|
||||
watchingUsers: [{ id: 1, name: 'tester' }],
|
||||
watchingUsers,
|
||||
skipVote: {
|
||||
votesCount: 0,
|
||||
requiredCount: 2,
|
||||
watchingUsersCount: 1,
|
||||
watchingUsersCount: watchingUsers.length,
|
||||
voted: false,
|
||||
},
|
||||
}))
|
||||
@@ -150,11 +154,11 @@ const mockDefaultApi = () => {
|
||||
postId: currentPost.id,
|
||||
postStartedAt: '2026-01-02T03:04:05.000Z',
|
||||
postElapsedMs: 2_000,
|
||||
watchingUsers: [{ id: 1, name: 'tester' }],
|
||||
watchingUsers,
|
||||
skipVote: {
|
||||
votesCount: 1,
|
||||
requiredCount: 2,
|
||||
watchingUsersCount: 1,
|
||||
watchingUsersCount: watchingUsers.length,
|
||||
voted: true,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Material,
|
||||
Post,
|
||||
Tag,
|
||||
TagWithSections,
|
||||
Theatre,
|
||||
TheatreComment,
|
||||
TheatreInfo,
|
||||
@@ -9,7 +9,7 @@ import type { Material,
|
||||
User,
|
||||
WikiPage } from '@/types'
|
||||
|
||||
export const buildTag = (overrides: Partial<Tag> = {}): Tag => ({
|
||||
export const buildTag = (overrides: Partial<TagWithSections> = {}): TagWithSections => ({
|
||||
id: 1,
|
||||
name: 'テストタグ',
|
||||
category: 'general',
|
||||
@@ -23,6 +23,8 @@ export const buildTag = (overrides: Partial<Tag> = {}): Tag => ({
|
||||
materialId: null,
|
||||
hasDeerjikists: false,
|
||||
matchedAlias: null,
|
||||
sections: [],
|
||||
children: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -33,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: [],
|
||||
|
||||
+7
-1
@@ -288,11 +288,12 @@ export type Post = {
|
||||
title: string | null
|
||||
thumbnail: string | null
|
||||
thumbnailBase: string | null
|
||||
videoMs: number | null
|
||||
postSimilarityEdges?: {
|
||||
targetPostId: number
|
||||
cos: number
|
||||
}[]
|
||||
tags: Tag[]
|
||||
tags: TagWithSections[]
|
||||
parentPosts?: Post[]
|
||||
childPosts?: Post[]
|
||||
siblingPosts?: Record<`${ number }`, Post[]>
|
||||
@@ -320,6 +321,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
|
||||
@@ -369,6 +371,10 @@ export type TagVersion = {
|
||||
createdAt: string
|
||||
createdByUser: { id: number; name: string | null } | null }
|
||||
|
||||
export type TagWithSections = Omit<Tag, 'children'> & { sections: { beginMs: number
|
||||
endMs: number | null }[]
|
||||
children: TagWithSections[] }
|
||||
|
||||
export type Theatre = {
|
||||
id: number
|
||||
name: string | null
|
||||
|
||||
新しい課題から参照
ユーザをブロックする