コミットを比較
7 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 3f2cddcd07 | |||
| 88b0195e03 | |||
| 7e0b6910f6 | |||
| e0aa14f0b5 | |||
| fdf652951a | |||
| f96ddb65f1 | |||
| 5657fa7e7b |
@@ -898,6 +898,22 @@ 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)
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -6,6 +6,7 @@ import { BrowserRouter,
|
|||||||
Routes,
|
Routes,
|
||||||
useLocation } from 'react-router-dom'
|
useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
|
import DevModeWatermark from '@/components/DevModeWatermark'
|
||||||
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
|
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
|
||||||
import TopNav from '@/components/TopNav'
|
import TopNav from '@/components/TopNav'
|
||||||
import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
||||||
@@ -145,6 +146,7 @@ const App: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RouteBlockerOverlay/>
|
<RouteBlockerOverlay/>
|
||||||
|
{import.meta.env.DEV && <DevModeWatermark/>}
|
||||||
|
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<DialogueProvider>
|
<DialogueProvider>
|
||||||
@@ -152,7 +154,7 @@ const App: FC = () => {
|
|||||||
<motion.div
|
<motion.div
|
||||||
layout="position"
|
layout="position"
|
||||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||||
className="flex flex-col h-dvh w-full overflow-y-hidden">
|
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
|
||||||
<TopNav user={user}/>
|
<TopNav user={user}/>
|
||||||
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import nikumaru from '@/assets/fonts/nikumaru.otf'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
|
||||||
|
const ROW_COUNT = 8
|
||||||
|
const COLUMN_COUNT = 12
|
||||||
|
|
||||||
|
|
||||||
|
const DevModeWatermark: FC = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none select-none fixed inset-0 overflow-hidden z-0">
|
||||||
|
<style>{`
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Nikumaru';
|
||||||
|
src: url(${nikumaru}) format('opentype');
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div className="absolute -inset-32 flex flex-col justify-center gap-12 py-48">
|
||||||
|
{Array.from ({ length: ROW_COUNT }, (_, rowIndex) => (
|
||||||
|
<div
|
||||||
|
key={rowIndex}
|
||||||
|
className={
|
||||||
|
'flex min-h-32 items-center gap-12 '
|
||||||
|
+ (rowIndex % 2 === 0 ? 'translate-x-0' : 'translate-x-32')
|
||||||
|
}>
|
||||||
|
{Array.from ({ length: COLUMN_COUNT }, (_, columnIndex) => (
|
||||||
|
<span
|
||||||
|
key={columnIndex}
|
||||||
|
className={
|
||||||
|
'whitespace-nowrap text-3xl font-bold '
|
||||||
|
+ 'tracking-[0.3em] text-neutral-950/5 '
|
||||||
|
+ 'dark:text-white/10'
|
||||||
|
}
|
||||||
|
style={{ fontFamily: 'Nikumaru' }}>
|
||||||
|
開発モード
|
||||||
|
</span>))}
|
||||||
|
</div>))}
|
||||||
|
</div>
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default DevModeWatermark
|
||||||
@@ -87,10 +87,13 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
|
|||||||
setDropRef (node)
|
setDropRef (node)
|
||||||
}}
|
}}
|
||||||
style={style}
|
style={style}
|
||||||
className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')}
|
className={cn (
|
||||||
|
'min-w-0 max-w-full overflow-hidden rounded select-none',
|
||||||
|
over && 'ring-2 ring-offset-2')}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}>
|
{...listeners}>
|
||||||
<motion.div
|
<motion.div
|
||||||
|
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
|
||||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||||
layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
|
layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
|
||||||
<TagLink tag={tag} nestLevel={nestLevel}/>
|
<TagLink tag={tag} nestLevel={nestLevel}/>
|
||||||
|
|||||||
@@ -318,12 +318,20 @@ const MobileMaterialTreeNode: FC<{ depth?: number
|
|||||||
<TagLink
|
<TagLink
|
||||||
tag={sidebarTagToTag (tag)}
|
tag={sidebarTagToTag (tag)}
|
||||||
title={tag.name}
|
title={tag.name}
|
||||||
|
truncateOnMobile
|
||||||
withCount={false}
|
withCount={false}
|
||||||
withWiki={false}
|
withWiki={false}
|
||||||
to={materialPath (tag.id, materialFilter)}
|
to={materialPath (tag.id, materialFilter)}
|
||||||
className="block overflow-hidden text-ellipsis whitespace-nowrap
|
className="block overflow-hidden text-ellipsis whitespace-nowrap
|
||||||
[max-inline-size:var(--tag-link-inline-size)]
|
[max-inline-size:var(--tag-link-inline-size)]
|
||||||
[max-height:var(--tag-link-inline-size)]"/>
|
[max-height:var(--tag-link-inline-size)]
|
||||||
|
[&_.tag-marquee]:block
|
||||||
|
[&_.tag-marquee]:h-full
|
||||||
|
[&_.tag-marquee]:overflow-hidden
|
||||||
|
[&_.tag-marquee__static]:h-full
|
||||||
|
[&_.tag-marquee__static]:overflow-hidden
|
||||||
|
[&_.tag-marquee__static]:text-ellipsis
|
||||||
|
[&_.tag-marquee__static]:[text-overflow:ellipsis]"/>
|
||||||
</div>
|
</div>
|
||||||
{tag.hasChildren && (
|
{tag.hasChildren && (
|
||||||
<button
|
<button
|
||||||
@@ -480,8 +488,7 @@ const MaterialSidebar: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden md:block">
|
<SidebarComponent sidebarKey="materials" className="hidden md:block">
|
||||||
<SidebarComponent>
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<MaterialFilterButtons
|
<MaterialFilterButtons
|
||||||
materialFilter={materialFilter}
|
materialFilter={materialFilter}
|
||||||
@@ -498,7 +505,6 @@ const MaterialSidebar: FC = () => {
|
|||||||
</ul>)}
|
</ul>)}
|
||||||
</div>
|
</div>
|
||||||
</SidebarComponent>
|
</SidebarComponent>
|
||||||
</div>
|
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,4 +66,27 @@ 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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -153,11 +153,6 @@ 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}/>
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
text: string
|
||||||
|
className?: string
|
||||||
|
truncateOnMobile?: boolean
|
||||||
|
title?: string }
|
||||||
|
|
||||||
|
const DESKTOP_MARQUEE_MEDIA =
|
||||||
|
'(min-width: 768px) and (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)'
|
||||||
|
const MARQUEE_START_DELAY_MS = 1600
|
||||||
|
const MARQUEE_END_HOLD_MS = 2400
|
||||||
|
const MARQUEE_SCROLL_PX_PER_SECOND = 43
|
||||||
|
const MIN_MARQUEE_OVERFLOW_PX = 1
|
||||||
|
|
||||||
|
|
||||||
|
const ResponsiveMarqueeText: FC<Props> = (
|
||||||
|
{ text, className, truncateOnMobile = false, title },
|
||||||
|
) => {
|
||||||
|
const outerRef = useRef<HTMLSpanElement | null> (null)
|
||||||
|
const staticRef = useRef<HTMLSpanElement | null> (null)
|
||||||
|
const animatedRef = useRef<HTMLSpanElement | null> (null)
|
||||||
|
const animationRef = useRef<Animation | null> (null)
|
||||||
|
const timeoutRef = useRef<number | null> (null)
|
||||||
|
const rafRef = useRef<number | null> (null)
|
||||||
|
const [overflowPx, setOverflowPx] = useState (0)
|
||||||
|
const [desktopMarqueeEnabled, setDesktopMarqueeEnabled] = useState (false)
|
||||||
|
const [active, setActive] = useState (false)
|
||||||
|
const [marqueeVisible, setMarqueeVisible] = useState (false)
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
const outer = outerRef.current
|
||||||
|
const inner = staticRef.current
|
||||||
|
|
||||||
|
if (!(outer) || !(inner))
|
||||||
|
return
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
const nextOverflow = Math.max (0, Math.ceil (inner.scrollWidth - outer.clientWidth))
|
||||||
|
setOverflowPx (prev => Math.abs (prev - nextOverflow) <= 1 ? prev : nextOverflow)
|
||||||
|
}
|
||||||
|
|
||||||
|
measure ()
|
||||||
|
|
||||||
|
const resizeObserver =
|
||||||
|
typeof ResizeObserver === 'undefined'
|
||||||
|
? null
|
||||||
|
: new ResizeObserver (() => {
|
||||||
|
measure ()
|
||||||
|
})
|
||||||
|
|
||||||
|
resizeObserver?.observe (outer)
|
||||||
|
resizeObserver?.observe (inner)
|
||||||
|
addEventListener ('resize', measure)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver?.disconnect ()
|
||||||
|
removeEventListener ('resize', measure)
|
||||||
|
}
|
||||||
|
}, [text])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
||||||
|
return
|
||||||
|
|
||||||
|
const media = window.matchMedia (DESKTOP_MARQUEE_MEDIA)
|
||||||
|
const update = () => {
|
||||||
|
setDesktopMarqueeEnabled (media.matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
update ()
|
||||||
|
media.addEventListener ('change', update)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
media.removeEventListener ('change', update)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
const clearScheduled = () => {
|
||||||
|
if (timeoutRef.current != null)
|
||||||
|
{
|
||||||
|
clearTimeout (timeoutRef.current)
|
||||||
|
timeoutRef.current = null
|
||||||
|
}
|
||||||
|
if (rafRef.current != null)
|
||||||
|
{
|
||||||
|
cancelAnimationFrame (rafRef.current)
|
||||||
|
rafRef.current = null
|
||||||
|
}
|
||||||
|
animationRef.current?.cancel ()
|
||||||
|
animationRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const animated = animatedRef.current
|
||||||
|
const canMarquee = (
|
||||||
|
active
|
||||||
|
&& desktopMarqueeEnabled
|
||||||
|
&& overflowPx >= MIN_MARQUEE_OVERFLOW_PX
|
||||||
|
&& animated != null)
|
||||||
|
|
||||||
|
const resetAnimated = () => {
|
||||||
|
const node = animatedRef.current
|
||||||
|
|
||||||
|
animationRef.current?.cancel ()
|
||||||
|
animationRef.current = null
|
||||||
|
node?.getAnimations?.().forEach (animation => {
|
||||||
|
animation.cancel ()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!(node))
|
||||||
|
return
|
||||||
|
|
||||||
|
node.style.transition = 'none'
|
||||||
|
node.style.transform = 'translateX(0)'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canMarquee)
|
||||||
|
{
|
||||||
|
clearScheduled ()
|
||||||
|
resetAnimated ()
|
||||||
|
setMarqueeVisible (false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const sleep = (ms: number) =>
|
||||||
|
new Promise<void> (resolve => {
|
||||||
|
timeoutRef.current = window.setTimeout (() => {
|
||||||
|
timeoutRef.current = null
|
||||||
|
resolve ()
|
||||||
|
}, ms)
|
||||||
|
})
|
||||||
|
|
||||||
|
const runLoop = async () => {
|
||||||
|
while (!cancelled)
|
||||||
|
{
|
||||||
|
resetAnimated ()
|
||||||
|
setMarqueeVisible (true)
|
||||||
|
|
||||||
|
await sleep (MARQUEE_START_DELAY_MS)
|
||||||
|
if (cancelled || !(animatedRef.current))
|
||||||
|
break
|
||||||
|
|
||||||
|
const moveDurationMs =
|
||||||
|
overflowPx / MARQUEE_SCROLL_PX_PER_SECOND * 1000
|
||||||
|
const node = animatedRef.current
|
||||||
|
|
||||||
|
if (typeof node.animate === 'function')
|
||||||
|
{
|
||||||
|
const animation = node.animate (
|
||||||
|
[
|
||||||
|
{ transform: 'translateX(0)' },
|
||||||
|
{ transform: `translateX(-${ overflowPx }px)` },
|
||||||
|
],
|
||||||
|
{ duration: moveDurationMs, easing: 'linear', fill: 'forwards' })
|
||||||
|
animationRef.current = animation
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await animation.finished
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
node.style.transform = `translateX(-${ overflowPx }px)`
|
||||||
|
animation.cancel ()
|
||||||
|
if (animationRef.current === animation)
|
||||||
|
animationRef.current = null
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
node.style.transition = `transform ${ moveDurationMs }ms linear`
|
||||||
|
await new Promise<void> (resolve => {
|
||||||
|
rafRef.current = requestAnimationFrame (() => {
|
||||||
|
rafRef.current = null
|
||||||
|
node.style.transform = `translateX(-${ overflowPx }px)`
|
||||||
|
resolve ()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await sleep (moveDurationMs)
|
||||||
|
node.style.transition = 'none'
|
||||||
|
node.style.transform = `translateX(-${ overflowPx }px)`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled)
|
||||||
|
break
|
||||||
|
|
||||||
|
await sleep (MARQUEE_END_HOLD_MS)
|
||||||
|
if (cancelled)
|
||||||
|
break
|
||||||
|
|
||||||
|
resetAnimated ()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void runLoop ()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
clearScheduled ()
|
||||||
|
resetAnimated ()
|
||||||
|
setMarqueeVisible (false)
|
||||||
|
}
|
||||||
|
}, [active, desktopMarqueeEnabled, overflowPx, text])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
ref={outerRef}
|
||||||
|
title={title ?? text}
|
||||||
|
onMouseEnter={() => setActive (true)}
|
||||||
|
onMouseLeave={() => setActive (false)}
|
||||||
|
onFocus={() => setActive (true)}
|
||||||
|
onBlur={() => setActive (false)}
|
||||||
|
className={cn (
|
||||||
|
'tag-marquee inline-block max-w-full min-w-0 align-bottom',
|
||||||
|
(truncateOnMobile
|
||||||
|
? 'overflow-hidden text-ellipsis whitespace-nowrap'
|
||||||
|
: 'whitespace-normal [overflow-wrap:anywhere]'),
|
||||||
|
'md:overflow-hidden md:whitespace-nowrap',
|
||||||
|
className)}>
|
||||||
|
<span
|
||||||
|
ref={staticRef}
|
||||||
|
className={cn (
|
||||||
|
'tag-marquee__static block max-w-full',
|
||||||
|
(truncateOnMobile
|
||||||
|
? 'overflow-hidden text-ellipsis whitespace-nowrap'
|
||||||
|
: 'whitespace-normal [overflow-wrap:anywhere]'),
|
||||||
|
'md:overflow-hidden md:text-ellipsis md:whitespace-nowrap',
|
||||||
|
marqueeVisible && 'md:opacity-0')}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
{overflowPx >= MIN_MARQUEE_OVERFLOW_PX && (
|
||||||
|
<span
|
||||||
|
ref={animatedRef}
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn (
|
||||||
|
'tag-marquee__animated hidden md:block',
|
||||||
|
marqueeVisible ? 'md:opacity-100' : 'md:opacity-0')}>
|
||||||
|
{text}
|
||||||
|
</span>)}
|
||||||
|
</span>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ResponsiveMarqueeText
|
||||||
@@ -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)
|
sp = Boolean (sp)
|
||||||
|
|
||||||
const qc = useQueryClient ()
|
const qc = useQueryClient ()
|
||||||
@@ -278,7 +281,7 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
|
|||||||
}, [baseTags])
|
}, [baseTags])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarComponent>
|
<SidebarComponent sidebarKey="post-detail-tags" className={className}>
|
||||||
<TagSearch/>
|
<TagSearch/>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
|
import ResponsiveMarqueeText from '@/components/ResponsiveMarqueeText'
|
||||||
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
|
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ import type { Tag } from '@/types'
|
|||||||
type CommonProps = {
|
type CommonProps = {
|
||||||
tag: Tag
|
tag: Tag
|
||||||
nestLevel?: number
|
nestLevel?: number
|
||||||
|
truncateOnMobile?: boolean
|
||||||
withWiki?: boolean
|
withWiki?: boolean
|
||||||
withCount?: boolean }
|
withCount?: boolean }
|
||||||
|
|
||||||
@@ -30,9 +32,11 @@ type Props =
|
|||||||
const TagLink: FC<Props> = ({ tag,
|
const TagLink: FC<Props> = ({ tag,
|
||||||
nestLevel = 0,
|
nestLevel = 0,
|
||||||
linkFlg = true,
|
linkFlg = true,
|
||||||
|
truncateOnMobile = false,
|
||||||
withWiki = true,
|
withWiki = true,
|
||||||
withCount = true,
|
withCount = true,
|
||||||
className,
|
className,
|
||||||
|
title,
|
||||||
...props }) => {
|
...props }) => {
|
||||||
const spanClass = cn (
|
const spanClass = cn (
|
||||||
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
|
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
|
||||||
@@ -41,11 +45,18 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
spanClass,
|
spanClass,
|
||||||
`hover:text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE - 200 }`,
|
`hover:text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE - 200 }`,
|
||||||
`dark:hover:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE - 200 }`)
|
`dark:hover:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE - 200 }`)
|
||||||
|
const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom'
|
||||||
|
const rootClass =
|
||||||
|
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline gap-x-1 md:items-baseline'
|
||||||
|
const markerWrapClass = 'shrink-0 self-start md:self-auto'
|
||||||
|
const countClass = 'shrink-0 self-end md:self-auto'
|
||||||
|
const textTitle = title
|
||||||
|
?? (tag.matchedAlias == null ? tag.name : `${ tag.matchedAlias } → ${ tag.name }`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<span className={rootClass}>
|
||||||
{(linkFlg && withWiki) && (
|
{(linkFlg && withWiki) && (
|
||||||
<span className="mr-1">
|
<span className={markerWrapClass}>
|
||||||
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
|
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
|
||||||
? (
|
? (
|
||||||
tag.materialId == null && !(tag.hasDeerjikists)
|
tag.materialId == null && !(tag.hasDeerjikists)
|
||||||
@@ -100,33 +111,48 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
</span>)}
|
</span>)}
|
||||||
{nestLevel > 0 && (
|
{nestLevel > 0 && (
|
||||||
<span
|
<span
|
||||||
className="ml-1 mr-1"
|
className="ml-1 mr-1 shrink-0"
|
||||||
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
|
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
|
||||||
↳
|
↳
|
||||||
</span>)}
|
</span>)}
|
||||||
{tag.matchedAlias != null && (
|
{tag.matchedAlias != null && (
|
||||||
<>
|
<>
|
||||||
<span className={cn (spanClass, className)} {...props}>
|
<span
|
||||||
{tag.matchedAlias}
|
title={textTitle}
|
||||||
|
className={cn (spanClass, textClass, className)}
|
||||||
|
{...props}>
|
||||||
|
<ResponsiveMarqueeText
|
||||||
|
text={tag.matchedAlias}
|
||||||
|
title={textTitle}
|
||||||
|
truncateOnMobile={truncateOnMobile}/>
|
||||||
</span>
|
</span>
|
||||||
<> → </>
|
<span className="shrink-0"> → </span>
|
||||||
</>)}
|
</>)}
|
||||||
{linkFlg
|
{linkFlg
|
||||||
? (
|
? (
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
|
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
|
||||||
className={cn (linkClass, className)}
|
title={textTitle}
|
||||||
|
className={cn (linkClass, textClass, className)}
|
||||||
{...props}>
|
{...props}>
|
||||||
{tag.name}
|
<ResponsiveMarqueeText
|
||||||
|
text={tag.name}
|
||||||
|
title={textTitle}
|
||||||
|
truncateOnMobile={truncateOnMobile}/>
|
||||||
</PrefetchLink>)
|
</PrefetchLink>)
|
||||||
: (
|
: (
|
||||||
<span className={cn (spanClass, className)}
|
<span
|
||||||
|
title={textTitle}
|
||||||
|
className={cn (spanClass, textClass, className)}
|
||||||
{...props}>
|
{...props}>
|
||||||
{tag.name}
|
<ResponsiveMarqueeText
|
||||||
|
text={tag.name}
|
||||||
|
title={textTitle}
|
||||||
|
truncateOnMobile={truncateOnMobile}/>
|
||||||
</span>)}
|
</span>)}
|
||||||
{withCount && (
|
{withCount && (
|
||||||
<span className="ml-1">{tag.postCount}</span>)}
|
<span className={countClass}>{tag.postCount}</span>)}
|
||||||
</>)
|
</span>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TagLink
|
export default TagLink
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ const TagSearchBox: FC<Props> = ({ suggestions, activeIndex, onSelect }) => {
|
|||||||
rounded shadow">
|
rounded shadow">
|
||||||
{suggestions.map ((tag, i) => (
|
{suggestions.map ((tag, i) => (
|
||||||
<li key={tag.id}
|
<li key={tag.id}
|
||||||
className={cn ('px-3 py-2 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-700',
|
className={cn ('min-w-0 overflow-hidden px-3 py-2 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-700',
|
||||||
i === activeIndex && 'bg-gray-300 dark:bg-gray-700')}
|
i === activeIndex && 'bg-gray-300 dark:bg-gray-700')}
|
||||||
onMouseDown={() => onSelect (tag)}>
|
onMouseDown={() => onSelect (tag)}>
|
||||||
|
<div className="flex min-w-0 max-w-full items-baseline overflow-hidden">
|
||||||
<TagLink tag={tag} linkFlg={false} withWiki={false}/>
|
<TagLink tag={tag} linkFlg={false} withWiki={false}/>
|
||||||
|
</div>
|
||||||
</li>))}
|
</li>))}
|
||||||
</ul>)
|
</ul>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,8 +64,9 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
|
|||||||
<ul>
|
<ul>
|
||||||
{CATEGORIES.flatMap (cat => cat in tags ? (
|
{CATEGORIES.flatMap (cat => cat in tags ? (
|
||||||
tags[cat].map (tag => (
|
tags[cat].map (tag => (
|
||||||
<li key={tag.id} className="mb-1">
|
<li key={tag.id} className="mb-1 min-w-0 max-w-full overflow-hidden">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
|
||||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||||
layoutId={`tag-${ tag.id }`}>
|
layoutId={`tag-${ tag.id }`}>
|
||||||
<TagLink tag={tag} onClick={onClick}/>
|
<TagLink tag={tag} onClick={onClick}/>
|
||||||
@@ -95,7 +96,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
|
|||||||
</>)
|
</>)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarComponent>
|
<SidebarComponent sidebarKey="posts-index-tags">
|
||||||
<TagSearch/>
|
<TagSearch/>
|
||||||
|
|
||||||
<div className="hidden md:block mt-4">
|
<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,16 +1,316 @@
|
|||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Helmet } from 'react-helmet-async'
|
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 }) => (
|
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
|
<motion.div
|
||||||
|
ref={rootRef}
|
||||||
layout="position"
|
layout="position"
|
||||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||||
className="p-4 w-full md:w-64 md:h-full md:overflow-y-auto sidebar">
|
style={style}
|
||||||
|
className={cn (
|
||||||
|
'relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full',
|
||||||
|
className)}>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<style>
|
<style>
|
||||||
{`
|
{`
|
||||||
@@ -26,7 +326,31 @@ const SidebarComponent: FC<Props> = ({ children }) => (
|
|||||||
</style>
|
</style>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
|
<div className="h-full overflow-y-auto p-4 sidebar">
|
||||||
{children}
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>)
|
</motion.div>)
|
||||||
|
}
|
||||||
|
|
||||||
export default SidebarComponent
|
export default SidebarComponent
|
||||||
@@ -132,3 +132,20 @@ body
|
|||||||
0%, 100% { color: #f87171; }
|
0%, 100% { color: #f87171; }
|
||||||
50% { color: #60a5fa; }
|
50% { color: #60a5fa; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-marquee
|
||||||
|
{
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-marquee__animated
|
||||||
|
{
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
min-width: 100%;
|
||||||
|
width: max-content;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ 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',
|
||||||
@@ -82,6 +83,7 @@ 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',
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import type { FC } from 'react'
|
|||||||
|
|
||||||
|
|
||||||
const MaterialBasePage: FC = () => (
|
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/>
|
<MaterialSidebar/>
|
||||||
<Outlet/>
|
<Outlet/>
|
||||||
</div>)
|
</div>)
|
||||||
|
|||||||
@@ -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 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>
|
<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>
|
||||||
|
|
||||||
<div className="hidden md:block">
|
{post && <TagDetailSidebar post={post} className="hidden md:block"/>}
|
||||||
{post && <TagDetailSidebar post={post}/>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MainArea className="relative">
|
<MainArea className="relative">
|
||||||
{post
|
{post
|
||||||
@@ -179,9 +179,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
|
|||||||
: 'Loading...'}
|
: 'Loading...'}
|
||||||
</MainArea>
|
</MainArea>
|
||||||
|
|
||||||
<div className="md:hidden">
|
{post && <TagDetailSidebar post={post} sp className="md:hidden"/>}
|
||||||
{post && <TagDetailSidebar post={post} sp/>}
|
|
||||||
</div>
|
|
||||||
</div>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ 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>
|
||||||
|
|||||||
@@ -62,6 +62,24 @@ 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)
|
||||||
|
|||||||
@@ -135,11 +135,6 @@ 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/>
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ 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 (
|
||||||
@@ -101,6 +105,20 @@ 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)
|
||||||
{
|
{
|
||||||
@@ -108,6 +126,10 @@ 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,
|
||||||
@@ -132,32 +154,22 @@ const mockDefaultApi = () => {
|
|||||||
switch (path)
|
switch (path)
|
||||||
{
|
{
|
||||||
case '/theatres/7/watching':
|
case '/theatres/7/watching':
|
||||||
return Promise.resolve (buildTheatreInfo ({
|
return Promise.resolve (currentTheatreInfo)
|
||||||
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':
|
||||||
return Promise.resolve (buildTheatreInfo ({
|
currentTheatreInfo = 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: [{ id: 1, name: 'tester' }],
|
watchingUsers,
|
||||||
skipVote: {
|
skipVote: {
|
||||||
votesCount: 1,
|
votesCount: 1,
|
||||||
requiredCount: 2,
|
requiredCount: 2,
|
||||||
watchingUsersCount: 1,
|
watchingUsersCount: watchingUsers.length,
|
||||||
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 }`))
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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'
|
||||||
@@ -52,6 +53,8 @@ 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 列',
|
||||||
@@ -211,11 +214,16 @@ 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)
|
||||||
@@ -258,6 +266,7 @@ 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)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -324,6 +333,17 @@ 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
|
||||||
@@ -387,6 +407,9 @@ 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
|
||||||
@@ -408,8 +431,9 @@ 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))
|
if (!(cancelled) && watchingRequestedAt >= theatreInfoReceivedAtRef.current)
|
||||||
applyTheatreInfo (nextInfo)
|
applyTheatreInfo (nextInfo)
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
@@ -538,6 +562,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
if (!(id) || !(post))
|
if (!(id) || !(post))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
loadingRef.current = true
|
||||||
setLoading (true)
|
setLoading (true)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -566,6 +591,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
loadingRef.current = false
|
||||||
setLoading (false)
|
setLoading (false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -639,6 +665,26 @@ 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">
|
||||||
@@ -806,30 +852,26 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
{theatre && <title>{`${ theatreTitle } | ${ SITE_TITLE }`}</title>}
|
{theatre && <title>{`${ theatreTitle } | ${ SITE_TITLE }`}</title>}
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<div className={cn (
|
<div
|
||||||
'grid min-h-full gap-4 overflow-visible md:h-full md:overflow-hidden',
|
ref={sidebarContainerRef}
|
||||||
(layoutMode === 'threeColumns'
|
data-sidebar-container
|
||||||
&& ['md:grid-cols-[16rem_minmax(0,1fr)_22rem]',
|
className="flex min-h-full flex-col gap-4 overflow-visible md:h-full md:flex-row
|
||||||
'xl:grid-cols-[18rem_minmax(0,1fr)_24rem]']),
|
md:overflow-hidden">
|
||||||
(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' && (
|
||||||
<motion.aside
|
<SidebarComponent
|
||||||
layout="position"
|
sidebarKey="theatre-tags"
|
||||||
className="hidden min-w-0 space-y-4 md:order-none md:block md:overflow-y-auto
|
side="left"
|
||||||
md:[direction:rtl]">
|
maxWidth={leftSidebarMaxWidth}
|
||||||
<div className="md:[direction:ltr]">
|
onWidthChange={setLeftSidebarWidth}
|
||||||
|
className="hidden md:block">
|
||||||
{tagPanel}
|
{tagPanel}
|
||||||
</div>
|
</SidebarComponent>)}
|
||||||
</motion.aside>)}
|
|
||||||
|
|
||||||
<motion.main
|
<motion.main
|
||||||
layout="position"
|
layout="position"
|
||||||
className={cn ('order-1 min-w-0 space-y-4 md:order-none md:overflow-y-auto',
|
className="order-1 min-w-0 flex-1 space-y-4 md:order-none
|
||||||
layoutMode === 'tagsBottom' && 'md:[direction:rtl]')}>
|
md:min-w-[360px] md:overflow-y-auto">
|
||||||
<div className={cn ('space-y-4', layoutMode === 'tagsBottom' && 'md:[direction:ltr]')}>
|
<div className="space-y-4">
|
||||||
<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
|
||||||
@@ -965,12 +1007,17 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
</motion.main>
|
</motion.main>
|
||||||
|
|
||||||
{layoutMode !== 'commentsBottom' && (
|
{layoutMode !== 'commentsBottom' && (
|
||||||
<motion.aside
|
<SidebarComponent
|
||||||
layout="position"
|
sidebarKey="theatre-comments"
|
||||||
className="hidden min-w-0 space-y-4 md:order-none md:block md:overflow-y-auto">
|
side="right"
|
||||||
|
maxWidth={rightSidebarMaxWidth}
|
||||||
|
onWidthChange={setRightSidebarWidth}
|
||||||
|
className="hidden md:block">
|
||||||
|
<div className="space-y-4">
|
||||||
{commentsPanel}
|
{commentsPanel}
|
||||||
{participantsPanel}
|
{participantsPanel}
|
||||||
</motion.aside>)}
|
</div>
|
||||||
|
</SidebarComponent>)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>)
|
</motion.div>)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -356,6 +356,7 @@ 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 = {
|
||||||
@@ -370,7 +371,7 @@ export type TagVersion = {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
createdByUser: { id: number; name: string | null } | null }
|
createdByUser: { id: number; name: string | null } | null }
|
||||||
|
|
||||||
export type TagWithSections = Tag & { sections: { beginMs: number
|
export type TagWithSections = Omit<Tag, 'children'> & { sections: { beginMs: number
|
||||||
endMs: number | null }[]
|
endMs: number | null }[]
|
||||||
children: TagWithSections[] }
|
children: TagWithSections[] }
|
||||||
|
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする