Reviewed-on: #396 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #396 でマージされました.
このコミットが含まれているのは:
@@ -488,25 +488,23 @@ const MaterialSidebar: FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<SidebarComponent>
|
||||
<div className="space-y-4">
|
||||
<MaterialFilterButtons
|
||||
materialFilter={materialFilter}
|
||||
onChange={handleFilterChange}/>
|
||||
{isLoading && (
|
||||
<p className="text-sm text-neutral-500 dark:text-stone-400">読込中……</p>)}
|
||||
{isError && (
|
||||
<p className="text-sm text-red-600 dark:text-red-300">
|
||||
タグ一覧の取得に失敗しました.
|
||||
</p>)}
|
||||
{(!isLoading && !isError) && (
|
||||
<ul>
|
||||
{renderDesktopTree (visibleRootTags)}
|
||||
</ul>)}
|
||||
</div>
|
||||
</SidebarComponent>
|
||||
</div>
|
||||
<SidebarComponent sidebarKey="materials" className="hidden md:block">
|
||||
<div className="space-y-4">
|
||||
<MaterialFilterButtons
|
||||
materialFilter={materialFilter}
|
||||
onChange={handleFilterChange}/>
|
||||
{isLoading && (
|
||||
<p className="text-sm text-neutral-500 dark:text-stone-400">読込中……</p>)}
|
||||
{isError && (
|
||||
<p className="text-sm text-red-600 dark:text-red-300">
|
||||
タグ一覧の取得に失敗しました.
|
||||
</p>)}
|
||||
{(!isLoading && !isError) && (
|
||||
<ul>
|
||||
{renderDesktopTree (visibleRootTags)}
|
||||
</ul>)}
|
||||
</div>
|
||||
</SidebarComponent>
|
||||
</>)
|
||||
}
|
||||
|
||||
|
||||
@@ -146,10 +146,13 @@ const DropSlot = ({ cat }: { cat: Category }) => {
|
||||
}
|
||||
|
||||
|
||||
type Props = { post: Post; sp?: boolean }
|
||||
type Props = {
|
||||
className?: string
|
||||
post: Post
|
||||
sp?: boolean }
|
||||
|
||||
|
||||
const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
|
||||
const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
||||
sp = Boolean (sp)
|
||||
|
||||
const qc = useQueryClient ()
|
||||
@@ -278,7 +281,7 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
|
||||
}, [baseTags])
|
||||
|
||||
return (
|
||||
<SidebarComponent>
|
||||
<SidebarComponent sidebarKey="post-detail-tags" className={className}>
|
||||
<TagSearch/>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
|
||||
@@ -96,7 +96,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
|
||||
</>)
|
||||
|
||||
return (
|
||||
<SidebarComponent>
|
||||
<SidebarComponent sidebarKey="posts-index-tags">
|
||||
<TagSearch/>
|
||||
|
||||
<div className="hidden md:block mt-4">
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,319 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Props = { children: ReactNode }
|
||||
import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react'
|
||||
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 SidebarComponent: FC<Props> = ({ children }) => (
|
||||
<motion.div
|
||||
layout="position"
|
||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||
className="p-4 w-full md:w-64 md:h-full md:overflow-y-auto sidebar">
|
||||
<Helmet>
|
||||
<style>
|
||||
{`
|
||||
const storageKeyForSidebar = (sidebarKey: string, side: SidebarSide): string =>
|
||||
`sidebar:${ sidebarKey }:${ side }:width`
|
||||
|
||||
|
||||
const getSidebarMaxWidth = (containerWidth: number): number => {
|
||||
const viewportWidth =
|
||||
typeof window === 'undefined' ? containerWidth : window.innerWidth
|
||||
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
|
||||
{
|
||||
direction: rtl;
|
||||
@@ -23,10 +323,34 @@ const SidebarComponent: FC<Props> = ({ children }) => (
|
||||
{
|
||||
direction: ltr;
|
||||
}`}
|
||||
</style>
|
||||
</Helmet>
|
||||
</style>
|
||||
</Helmet>
|
||||
|
||||
{children}
|
||||
</motion.div>)
|
||||
<div className="h-full overflow-y-auto p-4 sidebar">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
export default SidebarComponent
|
||||
<div
|
||||
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
|
||||
|
||||
@@ -6,9 +6,11 @@ import type { FC } from 'react'
|
||||
|
||||
|
||||
const MaterialBasePage: FC = () => (
|
||||
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
|
||||
<div
|
||||
data-sidebar-container
|
||||
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
|
||||
<MaterialSidebar/>
|
||||
<Outlet/>
|
||||
</div>)
|
||||
|
||||
export default MaterialBasePage
|
||||
export default MaterialBasePage
|
||||
|
||||
@@ -92,16 +92,16 @@ const PostDetailPage: FC<Props> = ({ user }) => {
|
||||
: 'bg-gray-500 hover:bg-gray-600')
|
||||
|
||||
return (
|
||||
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
|
||||
<div
|
||||
data-sidebar-container
|
||||
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
|
||||
<Helmet>
|
||||
{(post?.thumbnail || post?.thumbnailBase) && (
|
||||
<meta name="thumbnail" content={post.thumbnail! || post.thumbnailBase!}/>)}
|
||||
{post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>}
|
||||
</Helmet>
|
||||
|
||||
<div className="hidden md:block">
|
||||
{post && <TagDetailSidebar post={post}/>}
|
||||
</div>
|
||||
{post && <TagDetailSidebar post={post} className="hidden md:block"/>}
|
||||
|
||||
<MainArea className="relative">
|
||||
{post
|
||||
@@ -179,9 +179,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
|
||||
: 'Loading...'}
|
||||
</MainArea>
|
||||
|
||||
<div className="md:hidden">
|
||||
{post && <TagDetailSidebar post={post} sp/>}
|
||||
</div>
|
||||
{post && <TagDetailSidebar post={post} sp className="md:hidden"/>}
|
||||
</div>)
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ const PostListPage: FC = () => {
|
||||
|
||||
return (
|
||||
<div
|
||||
data-sidebar-container
|
||||
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"
|
||||
ref={containerRef}>
|
||||
<Helmet>
|
||||
|
||||
@@ -105,6 +105,20 @@ const tagSection = (): HTMLElement =>
|
||||
screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')!
|
||||
|
||||
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) => {
|
||||
switch (path)
|
||||
{
|
||||
@@ -112,13 +126,17 @@ const mockDefaultApi = () => {
|
||||
return Promise.resolve (theatre)
|
||||
|
||||
case '/theatres/7/comments':
|
||||
if (commentsFetched)
|
||||
return Promise.resolve ([])
|
||||
|
||||
commentsFetched = true
|
||||
return Promise.resolve ([
|
||||
buildTheatreComment ({
|
||||
theatreId: 7,
|
||||
no: 2,
|
||||
user: { id: 1, name: 'tester' },
|
||||
content: '視聴コメント',
|
||||
}),
|
||||
buildTheatreComment ({
|
||||
theatreId: 7,
|
||||
no: 2,
|
||||
user: { id: 1, name: 'tester' },
|
||||
content: '視聴コメント',
|
||||
}),
|
||||
])
|
||||
|
||||
case '/theatres/7/programmes':
|
||||
@@ -136,32 +154,22 @@ const mockDefaultApi = () => {
|
||||
switch (path)
|
||||
{
|
||||
case '/theatres/7/watching':
|
||||
return Promise.resolve (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,
|
||||
},
|
||||
}))
|
||||
return Promise.resolve (currentTheatreInfo)
|
||||
|
||||
case '/theatres/7/skip_vote':
|
||||
return Promise.resolve (buildTheatreInfo ({
|
||||
postId: currentPost.id,
|
||||
postStartedAt: '2026-01-02T03:04:05.000Z',
|
||||
postElapsedMs: 2_000,
|
||||
watchingUsers,
|
||||
skipVote: {
|
||||
votesCount: 1,
|
||||
requiredCount: 2,
|
||||
watchingUsersCount: watchingUsers.length,
|
||||
voted: true,
|
||||
},
|
||||
}))
|
||||
currentTheatreInfo = buildTheatreInfo ({
|
||||
postId: currentPost.id,
|
||||
postStartedAt: '2026-01-02T03:04:05.000Z',
|
||||
postElapsedMs: 2_000,
|
||||
watchingUsers,
|
||||
skipVote: {
|
||||
votesCount: 1,
|
||||
requiredCount: 2,
|
||||
watchingUsersCount: watchingUsers.length,
|
||||
voted: true,
|
||||
},
|
||||
})
|
||||
return Promise.resolve (currentTheatreInfo)
|
||||
|
||||
default:
|
||||
return Promise.reject (new Error (`Unexpected PUT ${ path }`))
|
||||
|
||||
@@ -10,6 +10,7 @@ import PrefetchLink from '@/components/PrefetchLink'
|
||||
import TagLink from '@/components/TagLink'
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
|
||||
@@ -52,6 +53,8 @@ const INITIAL_WEIGHTS: TheatrePostSelectionWeights =
|
||||
|
||||
const LAYOUT_STORAGE_KEY = 'theatre-layout-mode'
|
||||
const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
|
||||
const MIN_MAIN_WIDTH = 360
|
||||
const SIDEBAR_GAP_WIDTH = 16
|
||||
|
||||
const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = {
|
||||
threeColumns: '3 列',
|
||||
@@ -211,11 +214,16 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
const commentsRef = useRef<HTMLDivElement> (null)
|
||||
const embedRef = useRef<NiconicoViewerHandle> (null)
|
||||
const loadingRef = useRef (false)
|
||||
const sidebarContainerRef = useRef<HTMLDivElement> (null)
|
||||
const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO)
|
||||
const theatreInfoReceivedAtRef = useRef (performance.now ())
|
||||
const videoLengthRef = 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 [content, setContent] = useState ('')
|
||||
const [editingPost, setEditingPost] = useState<Post | null> (null)
|
||||
@@ -258,6 +266,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
|
||||
const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => {
|
||||
theatreInfoReceivedAtRef.current = performance.now ()
|
||||
theatreInfoRef.current = nextInfo
|
||||
setTheatreInfo (nextInfo)
|
||||
}, [])
|
||||
|
||||
@@ -324,6 +333,17 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
lastCommentNoRef.current = comments[0]?.no ?? 0
|
||||
}, [comments])
|
||||
|
||||
useEffect (() => {
|
||||
const updateSidebarContainerWidth = () => {
|
||||
const nextWidth = sidebarContainerRef.current?.clientWidth ?? window.innerWidth
|
||||
setSidebarContainerWidth (nextWidth)
|
||||
}
|
||||
|
||||
updateSidebarContainerWidth ()
|
||||
window.addEventListener ('resize', updateSidebarContainerWidth)
|
||||
return () => window.removeEventListener ('resize', updateSidebarContainerWidth)
|
||||
}, [])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(id))
|
||||
return
|
||||
@@ -387,6 +407,9 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
setComments (prev => [...newComments, ...prev])
|
||||
}
|
||||
|
||||
if (loadingRef.current)
|
||||
return
|
||||
|
||||
const currentInfo = theatreInfoRef.current
|
||||
const ended =
|
||||
currentInfo.hostFlg
|
||||
@@ -408,8 +431,9 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
return
|
||||
}
|
||||
|
||||
const watchingRequestedAt = performance.now ()
|
||||
const nextInfo = await apiPut<TheatreInfo> (`/theatres/${ id }/watching`)
|
||||
if (!(cancelled))
|
||||
if (!(cancelled) && watchingRequestedAt >= theatreInfoReceivedAtRef.current)
|
||||
applyTheatreInfo (nextInfo)
|
||||
}
|
||||
catch (error)
|
||||
@@ -538,6 +562,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
if (!(id) || !(post))
|
||||
return
|
||||
|
||||
loadingRef.current = true
|
||||
setLoading (true)
|
||||
try
|
||||
{
|
||||
@@ -566,6 +591,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingRef.current = false
|
||||
setLoading (false)
|
||||
}
|
||||
}
|
||||
@@ -639,6 +665,26 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
if (status >= 400)
|
||||
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 = (
|
||||
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
@@ -806,30 +852,26 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
{theatre && <title>{`${ theatreTitle } | ${ SITE_TITLE }`}</title>}
|
||||
</Helmet>
|
||||
|
||||
<div className={cn (
|
||||
'grid min-h-full gap-4 overflow-visible md:h-full md:overflow-hidden',
|
||||
(layoutMode === 'threeColumns'
|
||||
&& ['md:grid-cols-[16rem_minmax(0,1fr)_22rem]',
|
||||
'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)]'))}>
|
||||
<div
|
||||
ref={sidebarContainerRef}
|
||||
data-sidebar-container
|
||||
className="flex min-h-full flex-col gap-4 overflow-visible md:h-full md:flex-row
|
||||
md:overflow-hidden">
|
||||
{layoutMode !== 'tagsBottom' && (
|
||||
<motion.aside
|
||||
layout="position"
|
||||
className="hidden min-w-0 space-y-4 md:order-none md:block md:overflow-y-auto
|
||||
md:[direction:rtl]">
|
||||
<div className="md:[direction:ltr]">
|
||||
{tagPanel}
|
||||
</div>
|
||||
</motion.aside>)}
|
||||
<SidebarComponent
|
||||
sidebarKey="theatre-tags"
|
||||
side="left"
|
||||
maxWidth={leftSidebarMaxWidth}
|
||||
onWidthChange={setLeftSidebarWidth}
|
||||
className="hidden md:block">
|
||||
{tagPanel}
|
||||
</SidebarComponent>)}
|
||||
|
||||
<motion.main
|
||||
layout="position"
|
||||
className={cn ('order-1 min-w-0 space-y-4 md:order-none md:overflow-y-auto',
|
||||
layoutMode === 'tagsBottom' && 'md:[direction:rtl]')}>
|
||||
<div className={cn ('space-y-4', layoutMode === 'tagsBottom' && 'md:[direction:ltr]')}>
|
||||
className="order-1 min-w-0 flex-1 space-y-4 md:order-none
|
||||
md:min-w-[360px] md:overflow-y-auto">
|
||||
<div className="space-y-4">
|
||||
<section className="overflow-hidden rounded border-zinc-300
|
||||
dark:border-zinc-800">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3
|
||||
@@ -965,12 +1007,17 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
</motion.main>
|
||||
|
||||
{layoutMode !== 'commentsBottom' && (
|
||||
<motion.aside
|
||||
layout="position"
|
||||
className="hidden min-w-0 space-y-4 md:order-none md:block md:overflow-y-auto">
|
||||
{commentsPanel}
|
||||
{participantsPanel}
|
||||
</motion.aside>)}
|
||||
<SidebarComponent
|
||||
sidebarKey="theatre-comments"
|
||||
side="right"
|
||||
maxWidth={rightSidebarMaxWidth}
|
||||
onWidthChange={setRightSidebarWidth}
|
||||
className="hidden md:block">
|
||||
<div className="space-y-4">
|
||||
{commentsPanel}
|
||||
{participantsPanel}
|
||||
</div>
|
||||
</SidebarComponent>)}
|
||||
</div>
|
||||
</motion.div>)
|
||||
}
|
||||
|
||||
新しい課題から参照
ユーザをブロックする