diff --git a/frontend/src/components/MaterialSidebar.tsx b/frontend/src/components/MaterialSidebar.tsx index 8428c09..3fef9da 100644 --- a/frontend/src/components/MaterialSidebar.tsx +++ b/frontend/src/components/MaterialSidebar.tsx @@ -488,25 +488,23 @@ const MaterialSidebar: FC = () => { -
- -
- - {isLoading && ( -

読込中……

)} - {isError && ( -

- タグ一覧の取得に失敗しました. -

)} - {(!isLoading && !isError) && ( -
    - {renderDesktopTree (visibleRootTags)} -
)} -
-
-
+ +
+ + {isLoading && ( +

読込中……

)} + {isError && ( +

+ タグ一覧の取得に失敗しました. +

)} + {(!isLoading && !isError) && ( +
    + {renderDesktopTree (visibleRootTags)} +
)} +
+
) } diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index ccea9d6..10b7eb6 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -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 = ({ post, sp }) => { +const TagDetailSidebar: FC = ({ className, post, sp }) => { sp = Boolean (sp) const qc = useQueryClient () @@ -278,7 +281,7 @@ const TagDetailSidebar: FC = ({ post, sp }) => { }, [baseTags]) return ( - + = ({ posts, onClick }) => { ) return ( - +
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 54b9def..cf541fe 100644 --- a/frontend/src/pages/posts/PostDetailPage.tsx +++ b/frontend/src/pages/posts/PostDetailPage.tsx @@ -94,16 +94,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 @@ -183,9 +183,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 48be9ff..6f56c9d 100644 --- a/frontend/src/pages/posts/PostListPage.tsx +++ b/frontend/src/pages/posts/PostListPage.tsx @@ -122,6 +122,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 12d3fb9..2a99a9f 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' @@ -56,6 +57,11 @@ const INITIAL_THEATRE_INFO: TheatreInfo = const INITIAL_WEIGHTS: TheatrePostSelectionWeights = { tagPenalties: [], lightestPosts: [], heaviestPosts: [] } +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 列', tagsBottom: '2 列 A 型', @@ -214,11 +220,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) @@ -249,6 +260,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => { theatreInfoReceivedAtRef.current = performance.now () + theatreInfoRef.current = nextInfo setTheatreInfo (nextInfo) }, []) @@ -315,6 +327,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 @@ -378,6 +401,9 @@ const TheatreDetailPage: FC = ({ user }: Props) => { setComments (prev => [...newComments, ...prev]) } + if (loadingRef.current) + return + const currentInfo = theatreInfoRef.current const ended = currentInfo.hostFlg @@ -399,8 +425,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) @@ -529,6 +556,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { if (!(id) || !(post)) return + loadingRef.current = true setLoading (true) try { @@ -557,6 +585,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { } finally { + loadingRef.current = false setLoading (false) } } @@ -630,6 +659,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 = (
@@ -797,30 +846,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} +
+
)}
) }