diff --git a/frontend/src/components/layout/SidebarComponent.test.tsx b/frontend/src/components/layout/SidebarComponent.test.tsx
new file mode 100644
index 0000000..c618446
--- /dev/null
+++ b/frontend/src/components/layout/SidebarComponent.test.tsx
@@ -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
[0]>) => (
+
+
+
+ Sidebar body
+
+
+ )
+
+
+const renderSidebar = (
+ props: Partial[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')
+ })
+ })
+})
diff --git a/frontend/src/components/layout/SidebarComponent.tsx b/frontend/src/components/layout/SidebarComponent.tsx
index 779755e..ea4b015 100644
--- a/frontend/src/components/layout/SidebarComponent.tsx
+++ b/frontend/src/components/layout/SidebarComponent.tsx
@@ -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 = ({ children }) => (
-
-
-
-
+
+
- {children}
- )
+
+ {children}
+
-export default SidebarComponent
\ No newline at end of file
+
+ )
+}
+
+export default SidebarComponent
diff --git a/frontend/src/pages/materials/MaterialBasePage.tsx b/frontend/src/pages/materials/MaterialBasePage.tsx
index c7b6292..c444b79 100644
--- a/frontend/src/pages/materials/MaterialBasePage.tsx
+++ b/frontend/src/pages/materials/MaterialBasePage.tsx
@@ -6,9 +6,11 @@ import type { FC } from 'react'
const MaterialBasePage: FC = () => (
-
+
)
-export default MaterialBasePage
\ No newline at end of file
+export default MaterialBasePage
diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx
index e5e823d..015c16c 100644
--- a/frontend/src/pages/posts/PostDetailPage.tsx
+++ b/frontend/src/pages/posts/PostDetailPage.tsx
@@ -92,16 +92,16 @@ const PostDetailPage: FC
= ({ user }) => {
: 'bg-gray-500 hover:bg-gray-600')
return (
-
+
{(post?.thumbnail || post?.thumbnailBase) && (
)}
{post && {`${ post.title || post.url } | ${ SITE_TITLE }`}}
-
- {post && }
-
+ {post &&
}
{post
@@ -179,9 +179,7 @@ const PostDetailPage: FC = ({ user }) => {
: 'Loading...'}
-
- {post && }
-
+ {post &&
}
)
}
diff --git a/frontend/src/pages/posts/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx
index 9fe7d25..6b116c8 100644
--- a/frontend/src/pages/posts/PostListPage.tsx
+++ b/frontend/src/pages/posts/PostListPage.tsx
@@ -70,6 +70,7 @@ const PostListPage: FC = () => {
return (
diff --git a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx
index 2c87aba..745ea4e 100644
--- a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx
+++ b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx
@@ -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 }`))
diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx
index e4db3d2..c414048 100644
--- a/frontend/src/pages/theatres/TheatreDetailPage.tsx
+++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx
@@ -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 = {
threeColumns: '3 列',
@@ -211,11 +214,16 @@ const TheatreDetailPage: FC = ({ user }: Props) => {
const commentsRef = useRef (null)
const embedRef = useRef (null)
const loadingRef = useRef (false)
+ const sidebarContainerRef = useRef (null)
const theatreInfoRef = useRef (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 ([])
const [content, setContent] = useState ('')
const [editingPost, setEditingPost] = useState (null)
@@ -258,6 +266,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => {
const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => {
theatreInfoReceivedAtRef.current = performance.now ()
+ theatreInfoRef.current = nextInfo
setTheatreInfo (nextInfo)
}, [])
@@ -324,6 +333,17 @@ const TheatreDetailPage: FC = ({ 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 = ({ 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 = ({ user }: Props) => {
return
}
+ const watchingRequestedAt = performance.now ()
const nextInfo = await apiPut (`/theatres/${ id }/watching`)
- if (!(cancelled))
+ if (!(cancelled) && watchingRequestedAt >= theatreInfoReceivedAtRef.current)
applyTheatreInfo (nextInfo)
}
catch (error)
@@ -538,6 +562,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => {
if (!(id) || !(post))
return
+ loadingRef.current = true
setLoading (true)
try
{
@@ -566,6 +591,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => {
}
finally
{
+ loadingRef.current = false
setLoading (false)
}
}
@@ -639,6 +665,26 @@ const TheatreDetailPage: FC = ({ user }: Props) => {
if (status >= 400)
return
+ 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 = (
@@ -806,30 +852,26 @@ const TheatreDetailPage: FC
= ({ user }: Props) => {
{theatre && {`${ theatreTitle } | ${ SITE_TITLE }`}}
-
+
{layoutMode !== 'tagsBottom' && (
-
-
- {tagPanel}
-
- )}
+
+ {tagPanel}
+ )}
-
+ className="order-1 min-w-0 flex-1 space-y-4 md:order-none
+ md:min-w-[360px] md:overflow-y-auto">
+
- {commentsPanel}
- {participantsPanel}
- )}
+
+
+ {commentsPanel}
+ {participantsPanel}
+
+ )}
)
}