コミットを比較

..

8 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 60335b4d34 Merge remote-tracking branch 'origin/main' into feature/351 2026-07-03 00:57:41 +09:00
みてるぞ 46de995f8d Merge remote-tracking branch 'origin/main' into feature/351 2026-07-02 01:55:35 +09:00
みてるぞ 53d1cadefb #351 2026-06-23 00:24:04 +09:00
みてるぞ d3af4563ca #351 2026-06-22 21:24:55 +09:00
みてるぞ 469228a6ed Merge remote-tracking branch 'origin/main' into feature/351 2026-06-22 12:39:17 +09:00
みてるぞ 7e89fb286a #351 2026-05-23 07:21:15 +09:00
みてるぞ 7fcfc8b8aa #351 2026-05-23 03:50:54 +09:00
みてるぞ 7b6b24b9c5 #351 2026-05-22 03:29:18 +09:00
17個のファイルの変更117行の追加676行の削除
-16
ファイルの表示
@@ -898,22 +898,6 @@ RSpec.describe 'Posts API', type: :request do
expect(json.fetch('video_ms')).to eq(180_500) expect(json.fetch('video_ms')).to eq(180_500)
end end
it 'creates a video post with number input duration seconds' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'video post seconds',
url: 'https://example.com/video-post-seconds',
tags: '動画 spec_tag',
duration: '180.5',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
expect(Post.find(json.fetch('id')).video_ms).to eq(180_500)
expect(json.fetch('video_ms')).to eq(180_500)
end
it 'clears video_ms when the saved tags do not include 動画' do it 'clears video_ms when the saved tags do not include 動画' do
sign_in_as(member) sign_in_as(member)
+19 -17
ファイルの表示
@@ -488,23 +488,25 @@ const MaterialSidebar: FC = () => {
</div> </div>
</div> </div>
<SidebarComponent sidebarKey="materials" className="hidden md:block"> <div className="hidden md:block">
<div className="space-y-4"> <SidebarComponent>
<MaterialFilterButtons <div className="space-y-4">
materialFilter={materialFilter} <MaterialFilterButtons
onChange={handleFilterChange}/> materialFilter={materialFilter}
{isLoading && ( onChange={handleFilterChange}/>
<p className="text-sm text-neutral-500 dark:text-stone-400"></p>)} {isLoading && (
{isError && ( <p className="text-sm text-neutral-500 dark:text-stone-400"></p>)}
<p className="text-sm text-red-600 dark:text-red-300"> {isError && (
<p className="text-sm text-red-600 dark:text-red-300">
</p>)}
{(!isLoading && !isError) && ( </p>)}
<ul> {(!isLoading && !isError) && (
{renderDesktopTree (visibleRootTags)} <ul>
</ul>)} {renderDesktopTree (visibleRootTags)}
</div> </ul>)}
</SidebarComponent> </div>
</SidebarComponent>
</div>
</>) </>)
} }
-23
ファイルの表示
@@ -66,27 +66,4 @@ describe ('PostEditForm', () => {
expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 })) expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 }))
expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' }) 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)
})
}) })
+5
ファイルの表示
@@ -153,6 +153,11 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
setDuration (videoMsToDurationValue (post.videoMs)) setDuration (videoMsToDurationValue (post.videoMs))
}, [post]) }, [post])
useEffect (() => {
if (!(videoFlg))
setDuration ('')
}, [videoFlg])
return ( return (
<form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4"> <form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4">
<FieldError messages={baseErrors}/> <FieldError messages={baseErrors}/>
+3 -6
ファイルの表示
@@ -146,13 +146,10 @@ const DropSlot = ({ cat }: { cat: Category }) => {
} }
type Props = { type Props = { post: Post; sp?: boolean }
className?: string
post: Post
sp?: boolean }
const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => { const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
sp = Boolean (sp) sp = Boolean (sp)
const qc = useQueryClient () const qc = useQueryClient ()
@@ -281,7 +278,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
}, [baseTags]) }, [baseTags])
return ( return (
<SidebarComponent sidebarKey="post-detail-tags" className={className}> <SidebarComponent>
<TagSearch/> <TagSearch/>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
+1 -1
ファイルの表示
@@ -96,7 +96,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
</>) </>)
return ( return (
<SidebarComponent sidebarKey="posts-index-tags"> <SidebarComponent>
<TagSearch/> <TagSearch/>
<div className="hidden md:block mt-4"> <div className="hidden md:block mt-4">
-124
ファイルの表示
@@ -1,124 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SidebarComponent from '@/components/layout/SidebarComponent'
const sidebarWidth = (element: Element | null): string =>
(element as HTMLElement).style.getPropertyValue ('--sidebar-width')
const sidebarRoot = (container: HTMLElement): Element =>
container.querySelector ('[style*="--sidebar-width"]')!
const sidebarTree = (props: Partial<Parameters<typeof SidebarComponent>[0]>) => (
<HelmetProvider>
<div data-sidebar-container>
<SidebarComponent sidebarKey="test-sidebar" {...props}>
Sidebar body
</SidebarComponent>
</div>
</HelmetProvider>)
const renderSidebar = (
props: Partial<Parameters<typeof SidebarComponent>[0]> = {},
containerWidth = 800,
) => {
Object.defineProperty (HTMLElement.prototype, 'clientWidth', {
configurable: true,
get () {
return this.hasAttribute ('data-sidebar-container') ? containerWidth : 0
},
})
const rendered = render (sidebarTree (props))
return rendered
}
describe ('SidebarComponent', () => {
beforeEach (() => {
Object.defineProperty (window, 'innerWidth', {
configurable: true,
value: 1_000,
})
Object.defineProperty (HTMLElement.prototype, 'setPointerCapture', {
configurable: true,
value: vi.fn (),
})
Object.defineProperty (HTMLElement.prototype, 'releasePointerCapture', {
configurable: true,
value: vi.fn (),
})
Object.defineProperty (HTMLElement.prototype, 'hasPointerCapture', {
configurable: true,
value: vi.fn (() => true),
})
vi.spyOn (HTMLElement.prototype, 'setPointerCapture').mockImplementation (() => {
;
})
vi.spyOn (HTMLElement.prototype, 'releasePointerCapture').mockImplementation (() => {
;
})
vi.spyOn (HTMLElement.prototype, 'hasPointerCapture').mockReturnValue (true)
})
it ('uses maxWidth without applying the container main-width reserve twice', async () => {
localStorage.setItem ('sidebar:test-sidebar:left:width', '700')
const onWidthChange = vi.fn ()
const { container } = renderSidebar ({ maxWidth: 700, onWidthChange }, 800)
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('700px')
})
expect (onWidthChange).toHaveBeenLastCalledWith (700)
})
it ('resets width and removes only its storage key on double click', async () => {
localStorage.setItem ('sidebar:test-sidebar:right:width', '420')
localStorage.setItem ('sidebar:other-sidebar:right:width', '480')
const { container } = renderSidebar ({ side: 'right' }, 900)
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('420px')
})
fireEvent.doubleClick (screen.getByRole ('separator', { name: 'サイドバー幅' }))
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('256px')
})
expect (localStorage.getItem ('sidebar:test-sidebar:right:width')).toBeNull ()
expect (localStorage.getItem ('sidebar:other-sidebar:right:width')).toBe ('480')
})
it ('clamps current width on maxWidth changes without re-reading storage', async () => {
localStorage.setItem ('sidebar:test-sidebar:left:width', '420')
const { container, rerender } = renderSidebar ({ maxWidth: 420 }, 900)
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('420px')
})
rerender (sidebarTree ({ maxWidth: 300 }))
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('300px')
})
rerender (sidebarTree ({ maxWidth: 500 }))
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('300px')
})
})
})
+15 -339
ファイルの表示
@@ -1,319 +1,19 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { cn } from '@/lib/utils' import type { FC, ReactNode } from 'react'
import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react' type Props = { children: ReactNode }
import { useCallback, useEffect, useRef, useState } from 'react'
type SidebarSide = 'left' | 'right'
type Props = {
children: ReactNode
className?: string
maxWidth?: number
onWidthChange?: (width: number) => void
sidebarKey: string
side?: SidebarSide }
type ResizeState = {
pointerId: number
startWidth: number
startX: number
side: SidebarSide }
type PendingPressState = ResizeState & { startY: number }
const DEFAULT_SIDEBAR_WIDTH = 256
const MIN_SIDEBAR_WIDTH = 192
const MIN_MAIN_WIDTH = 360
const MAX_VIEWPORT_WIDTH_RATIO = .8
const LONG_PRESS_DELAY_MS = 350
const TOUCH_MOVE_THRESHOLD_PX = 8
const storageKeyForSidebar = (sidebarKey: string, side: SidebarSide): string => const SidebarComponent: FC<Props> = ({ children }) => (
`sidebar:${ sidebarKey }:${ side }:width` <motion.div
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
const getSidebarMaxWidth = (containerWidth: number): number => { className="p-4 w-full md:w-64 md:h-full md:overflow-y-auto sidebar">
const viewportWidth = <Helmet>
typeof window === 'undefined' ? containerWidth : window.innerWidth <style>
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO) {`
const containerMaxWidth = Math.floor (containerWidth - MIN_MAIN_WIDTH)
const dynamicMaxWidth = Math.min (viewportMaxWidth, containerMaxWidth)
return Math.max (MIN_SIDEBAR_WIDTH, dynamicMaxWidth)
}
const readStoredSidebarWidth = (
sidebarKey: string,
side: SidebarSide): number => {
if (typeof window === 'undefined')
return DEFAULT_SIDEBAR_WIDTH
const value = window.localStorage.getItem (storageKeyForSidebar (sidebarKey, side))
const width = value == null ? Number.NaN : Number (value)
return Number.isFinite (width) ? width : DEFAULT_SIDEBAR_WIDTH
}
const SidebarComponent: FC<Props> = ({
children,
className,
maxWidth,
onWidthChange,
sidebarKey,
side = 'left',
}) => {
const rootRef = useRef<HTMLDivElement | null> (null)
const maxWidthRef = useRef (maxWidth)
const getContainerWidth = useCallback (() => {
const containerWidth = rootRef.current
?.closest ('[data-sidebar-container]')
?.clientWidth
?? window.innerWidth
return containerWidth
}, [])
const getClampedWidth = useCallback ((
nextWidth: number,
containerWidth: number): number => {
const viewportWidth =
typeof window === 'undefined' ? containerWidth : window.innerWidth
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
const maxAllowedWidth =
maxWidthRef.current == null
? getSidebarMaxWidth (containerWidth)
: Math.max (
MIN_SIDEBAR_WIDTH,
Math.min (viewportMaxWidth, maxWidthRef.current))
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth))
}, [])
const [width, setWidth] = useState (() => (
typeof window === 'undefined'
? DEFAULT_SIDEBAR_WIDTH
: getClampedWidth (
readStoredSidebarWidth (sidebarKey, side),
window.innerWidth)))
const [resizing, setResizing] = useState (false)
const widthRef = useRef (width)
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
const pendingPressRef = useRef<PendingPressState | null> (null)
const resizeRef = useRef<ResizeState | null> (null)
const bodyStyleRef = useRef<{ cursor: string, userSelect: string } | null> (null)
const clearLongPressTimer = useCallback (() => {
if (longPressTimerRef.current == null)
return
clearTimeout (longPressTimerRef.current)
longPressTimerRef.current = null
}, [])
const restoreBodyStyle = useCallback (() => {
if (bodyStyleRef.current == null)
return
document.body.style.cursor = bodyStyleRef.current.cursor
document.body.style.userSelect = bodyStyleRef.current.userSelect
bodyStyleRef.current = null
}, [])
const startResize = useCallback ((state: ResizeState) => {
clearLongPressTimer ()
pendingPressRef.current = null
resizeRef.current = state
setResizing (true)
if (bodyStyleRef.current == null)
{
bodyStyleRef.current = {
cursor: document.body.style.cursor,
userSelect: document.body.style.userSelect }
}
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}, [clearLongPressTimer])
const finishResize = useCallback (() => {
clearLongPressTimer ()
pendingPressRef.current = null
const state = resizeRef.current
resizeRef.current = null
if (state != null)
window.localStorage.setItem (
storageKeyForSidebar (sidebarKey, state.side),
String (widthRef.current))
setResizing (false)
restoreBodyStyle ()
}, [clearLongPressTimer, restoreBodyStyle, sidebarKey])
const applyWidth = useCallback ((nextWidth: number) => {
if (nextWidth === widthRef.current)
return
widthRef.current = nextWidth
setWidth (nextWidth)
}, [])
useEffect (() => {
maxWidthRef.current = maxWidth
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth, maxWidth])
const updateWidth = useCallback ((clientX: number) => {
const state = resizeRef.current
if (state == null)
return
const containerWidth = getContainerWidth ()
const delta = state.side === 'left'
? clientX - state.startX
: state.startX - clientX
const nextWidth = getClampedWidth (state.startWidth + delta, containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth])
const handlePointerDown = (ev: PointerEvent<HTMLDivElement>) => {
if (ev.pointerType !== 'mouse' && ev.pointerType !== 'touch')
return
if (ev.pointerType === 'mouse' && ev.button !== 0)
return
ev.preventDefault ()
ev.stopPropagation ()
ev.currentTarget.setPointerCapture (ev.pointerId)
const state = {
pointerId: ev.pointerId,
side,
startWidth: widthRef.current,
startX: ev.clientX }
if (ev.pointerType === 'mouse')
{
startResize (state)
return
}
pendingPressRef.current = { ...state, startY: ev.clientY }
clearLongPressTimer ()
longPressTimerRef.current = setTimeout (() => {
if (pendingPressRef.current != null)
startResize (pendingPressRef.current)
}, LONG_PRESS_DELAY_MS)
}
const handlePointerMove = (ev: PointerEvent<HTMLDivElement>) => {
ev.stopPropagation ()
if (resizeRef.current != null)
{
ev.preventDefault ()
updateWidth (ev.clientX)
return
}
const pendingPress = pendingPressRef.current
if (pendingPress == null || pendingPress.pointerId !== ev.pointerId)
return
const deltaX = ev.clientX - pendingPress.startX
const deltaY = ev.clientY - pendingPress.startY
const distance = Math.hypot (deltaX, deltaY)
if (distance >= TOUCH_MOVE_THRESHOLD_PX)
{
clearLongPressTimer ()
pendingPressRef.current = null
}
}
const handlePointerEnd = (ev: PointerEvent<HTMLDivElement>) => {
ev.stopPropagation ()
if (ev.currentTarget.hasPointerCapture (ev.pointerId))
ev.currentTarget.releasePointerCapture (ev.pointerId)
finishResize ()
}
const resetWidth = useCallback (() => {
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (DEFAULT_SIDEBAR_WIDTH, containerWidth)
applyWidth (nextWidth)
window.localStorage.removeItem (storageKeyForSidebar (sidebarKey, side))
}, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side])
const handleDoubleClick = (ev: MouseEvent<HTMLDivElement>) => {
ev.preventDefault ()
ev.stopPropagation ()
resetWidth ()
}
useEffect (() => {
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (
readStoredSidebarWidth (sidebarKey, side),
containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side])
useEffect (() => {
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth])
useEffect (() => {
if (typeof window === 'undefined')
return
const handleResize = () => {
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applyWidth (nextWidth)
}
window.addEventListener ('resize', handleResize)
return () => window.removeEventListener ('resize', handleResize)
}, [applyWidth, getClampedWidth, getContainerWidth])
useEffect (() => {
onWidthChange?.(width)
}, [onWidthChange, width])
useEffect (() => () => {
clearLongPressTimer ()
restoreBodyStyle ()
}, [clearLongPressTimer, restoreBodyStyle])
const style = {
'--sidebar-width': `${ width }px` } as CSSProperties
return (
<motion.div
ref={rootRef}
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
style={style}
className={cn (
'relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full',
className)}>
<Helmet>
<style>
{`
.sidebar .sidebar
{ {
direction: rtl; direction: rtl;
@@ -323,34 +23,10 @@ const SidebarComponent: FC<Props> = ({
{ {
direction: ltr; direction: ltr;
}`} }`}
</style> </style>
</Helmet> </Helmet>
<div className="h-full overflow-y-auto p-4 sidebar"> {children}
{children} </motion.div>)
</div>
<div export default SidebarComponent
role="separator"
aria-orientation="vertical"
aria-label="サイドバー幅"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onDoubleClick={handleDoubleClick}
className={cn (
'hidden md:block absolute top-0 bottom-0 z-20 w-4 cursor-col-resize',
'touch-none select-none',
side === 'left' ? '-right-2' : '-left-2',
'before:absolute before:top-0 before:bottom-0 before:w-px',
'before:bg-neutral-500/0 hover:before:bg-neutral-500/30',
'before:transition-colors before:duration-150',
side === 'left'
? 'before:left-1/2'
: 'before:right-1/2',
resizing && 'before:bg-neutral-500/40')}/>
</motion.div>)
}
export default SidebarComponent
-2
ファイルの表示
@@ -70,7 +70,6 @@ describe ('posts API functions', () => {
id: 5, id: 5,
title: 'new title', title: 'new title',
tags: 'tag', tags: 'tag',
duration: null,
parentPostIds: '1 2', parentPostIds: '1 2',
originalCreatedFrom: null, originalCreatedFrom: null,
originalCreatedBefore: '2026-01-02T00:00:00Z', originalCreatedBefore: '2026-01-02T00:00:00Z',
@@ -83,7 +82,6 @@ describe ('posts API functions', () => {
{ {
title: 'new title', title: 'new title',
tags: 'tag', tags: 'tag',
duration: null,
parent_post_ids: '1 2', parent_post_ids: '1 2',
original_created_from: null, original_created_from: null,
original_created_before: '2026-01-02T00:00:00Z', original_created_before: '2026-01-02T00:00:00Z',
+2 -4
ファイルの表示
@@ -6,11 +6,9 @@ import type { FC } from 'react'
const MaterialBasePage: FC = () => ( const MaterialBasePage: FC = () => (
<div <div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
data-sidebar-container
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
<MaterialSidebar/> <MaterialSidebar/>
<Outlet/> <Outlet/>
</div>) </div>)
export default MaterialBasePage export default MaterialBasePage
+7 -5
ファイルの表示
@@ -92,16 +92,16 @@ const PostDetailPage: FC<Props> = ({ user }) => {
: 'bg-gray-500 hover:bg-gray-600') : 'bg-gray-500 hover:bg-gray-600')
return ( return (
<div <div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
data-sidebar-container
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
<Helmet> <Helmet>
{(post?.thumbnail || post?.thumbnailBase) && ( {(post?.thumbnail || post?.thumbnailBase) && (
<meta name="thumbnail" content={post.thumbnail! || post.thumbnailBase!}/>)} <meta name="thumbnail" content={post.thumbnail! || post.thumbnailBase!}/>)}
{post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>} {post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>}
</Helmet> </Helmet>
{post && <TagDetailSidebar post={post} className="hidden md:block"/>} <div className="hidden md:block">
{post && <TagDetailSidebar post={post}/>}
</div>
<MainArea className="relative"> <MainArea className="relative">
{post {post
@@ -179,7 +179,9 @@ const PostDetailPage: FC<Props> = ({ user }) => {
: 'Loading...'} : 'Loading...'}
</MainArea> </MainArea>
{post && <TagDetailSidebar post={post} sp className="md:hidden"/>} <div className="md:hidden">
{post && <TagDetailSidebar post={post} sp/>}
</div>
</div>) </div>)
} }
-1
ファイルの表示
@@ -70,7 +70,6 @@ const PostListPage: FC = () => {
return ( return (
<div <div
data-sidebar-container
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden" className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"
ref={containerRef}> ref={containerRef}>
<Helmet> <Helmet>
-18
ファイルの表示
@@ -62,24 +62,6 @@ describe ('PostNewPage', () => {
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' }) 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 () => { it ('shows 422 validation errors for post fields', async () => {
api.apiGet.mockResolvedValue ([]) api.apiGet.mockResolvedValue ([])
api.isApiError.mockReturnValue (true) api.isApiError.mockReturnValue (true)
+5
ファイルの表示
@@ -135,6 +135,11 @@ const PostNewPage: FC<Props> = ({ user }) => {
fetchThumbnail () fetchThumbnail ()
}, [fetchThumbnail, thumbnailAutoFlg, url]) }, [fetchThumbnail, thumbnailAutoFlg, url])
useEffect (() => {
if (!(videoFlg))
setDuration ('')
}, [videoFlg])
if (!(editable)) if (!(editable))
return <Forbidden/> return <Forbidden/>
+30 -42
ファイルの表示
@@ -88,10 +88,6 @@ const weights = buildTheatrePostSelectionWeights ({
tags: [], tags: [],
}], }],
}) })
const watchingUsers = [
{ id: 1, name: 'tester' },
{ id: 2, name: 'another' },
]
const renderPage = (user = buildUser ({ id: 1, role: 'member' })) => const renderPage = (user = buildUser ({ id: 1, role: 'member' })) =>
renderWithProviders ( renderWithProviders (
@@ -105,20 +101,6 @@ const tagSection = (): HTMLElement =>
screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')! screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')!
const mockDefaultApi = () => { const mockDefaultApi = () => {
let commentsFetched = false
let currentTheatreInfo = buildTheatreInfo ({
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 1_000,
watchingUsers,
skipVote: {
votesCount: 0,
requiredCount: 2,
watchingUsersCount: watchingUsers.length,
voted: false,
},
})
api.apiGet.mockImplementation ((path: string) => { api.apiGet.mockImplementation ((path: string) => {
switch (path) switch (path)
{ {
@@ -126,17 +108,13 @@ const mockDefaultApi = () => {
return Promise.resolve (theatre) return Promise.resolve (theatre)
case '/theatres/7/comments': case '/theatres/7/comments':
if (commentsFetched)
return Promise.resolve ([])
commentsFetched = true
return Promise.resolve ([ return Promise.resolve ([
buildTheatreComment ({ buildTheatreComment ({
theatreId: 7, theatreId: 7,
no: 2, no: 2,
user: { id: 1, name: 'tester' }, user: { id: 1, name: 'tester' },
content: '視聴コメント', content: '視聴コメント',
}), }),
]) ])
case '/theatres/7/programmes': case '/theatres/7/programmes':
@@ -154,22 +132,32 @@ const mockDefaultApi = () => {
switch (path) switch (path)
{ {
case '/theatres/7/watching': case '/theatres/7/watching':
return Promise.resolve (currentTheatreInfo) return Promise.resolve (buildTheatreInfo ({
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 1_000,
watchingUsers: [{ id: 1, name: 'tester' }],
skipVote: {
votesCount: 0,
requiredCount: 2,
watchingUsersCount: 1,
voted: false,
},
}))
case '/theatres/7/skip_vote': case '/theatres/7/skip_vote':
currentTheatreInfo = buildTheatreInfo ({ return Promise.resolve (buildTheatreInfo ({
postId: currentPost.id, postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z', postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 2_000, postElapsedMs: 2_000,
watchingUsers, watchingUsers: [{ id: 1, name: 'tester' }],
skipVote: { skipVote: {
votesCount: 1, votesCount: 1,
requiredCount: 2, requiredCount: 2,
watchingUsersCount: watchingUsers.length, watchingUsersCount: 1,
voted: true, voted: true,
}, },
}) }))
return Promise.resolve (currentTheatreInfo)
default: default:
return Promise.reject (new Error (`Unexpected PUT ${ path }`)) return Promise.reject (new Error (`Unexpected PUT ${ path }`))
+27 -74
ファイルの表示
@@ -10,7 +10,6 @@ import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import { useDialogue } from '@/components/dialogues/DialogueProvider' import { useDialogue } from '@/components/dialogues/DialogueProvider'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
@@ -53,8 +52,6 @@ const INITIAL_WEIGHTS: TheatrePostSelectionWeights =
const LAYOUT_STORAGE_KEY = 'theatre-layout-mode' const LAYOUT_STORAGE_KEY = 'theatre-layout-mode'
const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow' const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
const MIN_MAIN_WIDTH = 360
const SIDEBAR_GAP_WIDTH = 16
const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = { const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = {
threeColumns: '3 列', threeColumns: '3 列',
@@ -214,16 +211,11 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const commentsRef = useRef<HTMLDivElement> (null) const commentsRef = useRef<HTMLDivElement> (null)
const embedRef = useRef<NiconicoViewerHandle> (null) const embedRef = useRef<NiconicoViewerHandle> (null)
const loadingRef = useRef (false) const loadingRef = useRef (false)
const sidebarContainerRef = useRef<HTMLDivElement> (null)
const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO) const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO)
const theatreInfoReceivedAtRef = useRef (performance.now ()) const theatreInfoReceivedAtRef = useRef (performance.now ())
const videoLengthRef = useRef (0) const videoLengthRef = useRef (0)
const lastCommentNoRef = useRef (0) const lastCommentNoRef = useRef (0)
const [leftSidebarWidth, setLeftSidebarWidth] = useState (256)
const [rightSidebarWidth, setRightSidebarWidth] = useState (256)
const [sidebarContainerWidth, setSidebarContainerWidth] = useState (
() => typeof window === 'undefined' ? 0 : window.innerWidth)
const [comments, setComments] = useState<TheatreComment[]> ([]) const [comments, setComments] = useState<TheatreComment[]> ([])
const [content, setContent] = useState ('') const [content, setContent] = useState ('')
const [editingPost, setEditingPost] = useState<Post | null> (null) const [editingPost, setEditingPost] = useState<Post | null> (null)
@@ -266,7 +258,6 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => { const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => {
theatreInfoReceivedAtRef.current = performance.now () theatreInfoReceivedAtRef.current = performance.now ()
theatreInfoRef.current = nextInfo
setTheatreInfo (nextInfo) setTheatreInfo (nextInfo)
}, []) }, [])
@@ -333,17 +324,6 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
lastCommentNoRef.current = comments[0]?.no ?? 0 lastCommentNoRef.current = comments[0]?.no ?? 0
}, [comments]) }, [comments])
useEffect (() => {
const updateSidebarContainerWidth = () => {
const nextWidth = sidebarContainerRef.current?.clientWidth ?? window.innerWidth
setSidebarContainerWidth (nextWidth)
}
updateSidebarContainerWidth ()
window.addEventListener ('resize', updateSidebarContainerWidth)
return () => window.removeEventListener ('resize', updateSidebarContainerWidth)
}, [])
useEffect (() => { useEffect (() => {
if (!(id)) if (!(id))
return return
@@ -407,9 +387,6 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
setComments (prev => [...newComments, ...prev]) setComments (prev => [...newComments, ...prev])
} }
if (loadingRef.current)
return
const currentInfo = theatreInfoRef.current const currentInfo = theatreInfoRef.current
const ended = const ended =
currentInfo.hostFlg currentInfo.hostFlg
@@ -431,9 +408,8 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
return return
} }
const watchingRequestedAt = performance.now ()
const nextInfo = await apiPut<TheatreInfo> (`/theatres/${ id }/watching`) const nextInfo = await apiPut<TheatreInfo> (`/theatres/${ id }/watching`)
if (!(cancelled) && watchingRequestedAt >= theatreInfoReceivedAtRef.current) if (!(cancelled))
applyTheatreInfo (nextInfo) applyTheatreInfo (nextInfo)
} }
catch (error) catch (error)
@@ -562,7 +538,6 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
if (!(id) || !(post)) if (!(id) || !(post))
return return
loadingRef.current = true
setLoading (true) setLoading (true)
try try
{ {
@@ -591,7 +566,6 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
} }
finally finally
{ {
loadingRef.current = false
setLoading (false) setLoading (false)
} }
} }
@@ -665,26 +639,6 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
if (status >= 400) if (status >= 400)
return <ErrorScreen status={status}/> return <ErrorScreen status={status}/>
const singleSidebarMaxWidth = Math.max (
192,
sidebarContainerWidth - MIN_MAIN_WIDTH - SIDEBAR_GAP_WIDTH)
const leftSidebarMaxWidth = layoutMode === 'threeColumns'
? Math.max (
192,
sidebarContainerWidth
- rightSidebarWidth
- MIN_MAIN_WIDTH
- SIDEBAR_GAP_WIDTH * 2)
: singleSidebarMaxWidth
const rightSidebarMaxWidth = layoutMode === 'threeColumns'
? Math.max (
192,
sidebarContainerWidth
- leftSidebarWidth
- MIN_MAIN_WIDTH
- SIDEBAR_GAP_WIDTH * 2)
: singleSidebarMaxWidth
const tagPanel = ( const tagPanel = (
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800"> <section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
@@ -852,26 +806,30 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
{theatre && <title>{`${ theatreTitle } | ${ SITE_TITLE }`}</title>} {theatre && <title>{`${ theatreTitle } | ${ SITE_TITLE }`}</title>}
</Helmet> </Helmet>
<div <div className={cn (
ref={sidebarContainerRef} 'grid min-h-full gap-4 overflow-visible md:h-full md:overflow-hidden',
data-sidebar-container (layoutMode === 'threeColumns'
className="flex min-h-full flex-col gap-4 overflow-visible md:h-full md:flex-row && ['md:grid-cols-[16rem_minmax(0,1fr)_22rem]',
md:overflow-hidden"> 'xl:grid-cols-[18rem_minmax(0,1fr)_24rem]']),
(layoutMode === 'tagsBottom'
&& 'md:grid-cols-[minmax(0,1fr)_22rem] xl:grid-cols-[minmax(0,1fr)_24rem]'),
(layoutMode === 'commentsBottom'
&& 'md:grid-cols-[16rem_minmax(0,1fr)] xl:grid-cols-[18rem_minmax(0,1fr)]'))}>
{layoutMode !== 'tagsBottom' && ( {layoutMode !== 'tagsBottom' && (
<SidebarComponent <motion.aside
sidebarKey="theatre-tags" layout="position"
side="left" className="hidden min-w-0 space-y-4 md:order-none md:block md:overflow-y-auto
maxWidth={leftSidebarMaxWidth} md:[direction:rtl]">
onWidthChange={setLeftSidebarWidth} <div className="md:[direction:ltr]">
className="hidden md:block"> {tagPanel}
{tagPanel} </div>
</SidebarComponent>)} </motion.aside>)}
<motion.main <motion.main
layout="position" layout="position"
className="order-1 min-w-0 flex-1 space-y-4 md:order-none className={cn ('order-1 min-w-0 space-y-4 md:order-none md:overflow-y-auto',
md:min-w-[360px] md:overflow-y-auto"> layoutMode === 'tagsBottom' && 'md:[direction:rtl]')}>
<div className="space-y-4"> <div className={cn ('space-y-4', layoutMode === 'tagsBottom' && 'md:[direction:ltr]')}>
<section className="overflow-hidden rounded border-zinc-300 <section className="overflow-hidden rounded border-zinc-300
dark:border-zinc-800"> dark:border-zinc-800">
<div className="flex flex-wrap items-center justify-between gap-3 <div className="flex flex-wrap items-center justify-between gap-3
@@ -1007,17 +965,12 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
</motion.main> </motion.main>
{layoutMode !== 'commentsBottom' && ( {layoutMode !== 'commentsBottom' && (
<SidebarComponent <motion.aside
sidebarKey="theatre-comments" layout="position"
side="right" className="hidden min-w-0 space-y-4 md:order-none md:block md:overflow-y-auto">
maxWidth={rightSidebarMaxWidth} {commentsPanel}
onWidthChange={setRightSidebarWidth} {participantsPanel}
className="hidden md:block"> </motion.aside>)}
<div className="space-y-4">
{commentsPanel}
{participantsPanel}
</div>
</SidebarComponent>)}
</div> </div>
</motion.div>) </motion.div>)
} }
+3 -4
ファイルの表示
@@ -356,7 +356,6 @@ export type Tag = {
hasWiki: boolean hasWiki: boolean
materialId: number | null materialId: number | null
hasDeerjikists: boolean hasDeerjikists: boolean
children?: Tag[]
matchedAlias?: string | null } matchedAlias?: string | null }
export type TagVersion = { export type TagVersion = {
@@ -371,9 +370,9 @@ export type TagVersion = {
createdAt: string createdAt: string
createdByUser: { id: number; name: string | null } | null } createdByUser: { id: number; name: string | null } | null }
export type TagWithSections = Omit<Tag, 'children'> & { sections: { beginMs: number export type TagWithSections = Tag & { sections: { beginMs: number
endMs: number | null }[] endMs: number | null }[]
children: TagWithSections[] } children: TagWithSections[] }
export type Theatre = { export type Theatre = {
id: number id: number