nsfwapp/frontend/src/components/ui/PreviewScrubber.tsx
2026-02-24 18:30:30 +01:00

198 lines
5.6 KiB
TypeScript

// frontend\src\components\ui\PreviewScrubber.tsx
'use client'
import * as React from 'react'
type Props = {
imageCount: number
activeIndex?: number
onActiveIndexChange: (index: number | undefined) => void
onIndexClick?: (index: number) => void
className?: string
stepSeconds?: number
}
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n))
}
function formatClock(totalSeconds: number) {
const s = Math.max(0, Math.floor(totalSeconds))
const m = Math.floor(s / 60)
const sec = s % 60
return `${m}:${String(sec).padStart(2, '0')}`
}
export default function PreviewScrubber({
imageCount,
activeIndex,
onActiveIndexChange,
onIndexClick,
className,
stepSeconds = 0,
}: Props) {
const rootRef = React.useRef<HTMLDivElement | null>(null)
const rafRef = React.useRef<number | null>(null)
const pendingIndexRef = React.useRef<number | undefined>(undefined)
const flushPending = React.useCallback(() => {
rafRef.current = null
onActiveIndexChange(pendingIndexRef.current)
}, [onActiveIndexChange])
const setIndexThrottled = React.useCallback(
(index: number | undefined) => {
pendingIndexRef.current = index
if (rafRef.current != null) return
rafRef.current = window.requestAnimationFrame(flushPending)
},
[flushPending]
)
React.useEffect(() => {
return () => {
if (rafRef.current != null) {
window.cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
}, [])
const indexFromClientX = React.useCallback(
(clientX: number) => {
if (!rootRef.current || imageCount < 1) return undefined
const rect = rootRef.current.getBoundingClientRect()
if (rect.width <= 0) return undefined
const x = clientX - rect.left
const ratio = clamp(x / rect.width, 0, 0.999999)
const idx = clamp(Math.floor(ratio * imageCount), 0, imageCount - 1)
return idx
},
[imageCount]
)
const handlePointerMove = React.useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const idx = indexFromClientX(e.clientX)
setIndexThrottled(idx)
},
[indexFromClientX, setIndexThrottled]
)
const handlePointerEnter = React.useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const idx = indexFromClientX(e.clientX)
setIndexThrottled(idx)
},
[indexFromClientX, setIndexThrottled]
)
const handlePointerLeave = React.useCallback(() => {
setIndexThrottled(undefined)
}, [setIndexThrottled])
const handlePointerDown = React.useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
e.stopPropagation()
const idx = indexFromClientX(e.clientX)
setIndexThrottled(idx)
try {
e.currentTarget.setPointerCapture?.(e.pointerId)
} catch {}
},
[indexFromClientX, setIndexThrottled]
)
const handleClick = React.useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
if (!onIndexClick) return
const idx = indexFromClientX(e.clientX)
if (typeof idx === 'number') onIndexClick(idx)
},
[indexFromClientX, onIndexClick]
)
if (!imageCount || imageCount < 1) return null
const markerLeftPct =
typeof activeIndex === 'number'
? ((activeIndex + 0.5) / imageCount) * 100
: undefined
const label =
typeof activeIndex === 'number'
? stepSeconds > 0
? formatClock(activeIndex * stepSeconds)
: `${activeIndex + 1}/${imageCount}`
: ''
const showLabel = typeof activeIndex === 'number'
const labelLeftStyle =
typeof markerLeftPct === 'number'
? {
left: `clamp(24px, ${markerLeftPct}%, calc(100% - 24px))`,
transform: 'translateX(-50%)',
}
: undefined
return (
<div
ref={rootRef}
className={[
'relative h-7 w-full select-none touch-none cursor-col-resize',
// standardmäßig versteckt, nur bei Hover/Focus auf dem Parent-Video sichtbar
'opacity-0 transition-opacity duration-150',
'pointer-events-none',
'group-hover:opacity-100 group-focus-within:opacity-100',
'group-hover:pointer-events-auto group-focus-within:pointer-events-auto',
className,
]
.filter(Boolean)
.join(' ')}
onPointerMove={handlePointerMove}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
onPointerDown={handlePointerDown}
onMouseDown={(e) => e.stopPropagation()}
onClick={handleClick}
title={showLabel ? label : `Preview scrubber (${imageCount})`}
role="slider"
aria-label="Preview scrubber"
aria-valuemin={1}
aria-valuemax={imageCount}
aria-valuenow={typeof activeIndex === 'number' ? activeIndex + 1 : undefined}
>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-4 bg-white/35 ring-1 ring-white/40 backdrop-blur-[1px]">
{typeof markerLeftPct === 'number' ? (
<div
className="absolute inset-y-0 w-[2px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.35)]"
style={{
left: `${markerLeftPct}%`,
transform: 'translateX(-50%)',
}}
/>
) : null}
</div>
<div
className={[
'pointer-events-none absolute bottom-[19px] z-10',
'rounded bg-black/70 px-1.5 py-0.5',
'text-[11px] leading-none text-white whitespace-nowrap',
'transition-opacity duration-100',
showLabel ? 'opacity-100' : 'opacity-0',
'[text-shadow:_0_1px_2px_rgba(0,0,0,0.9)]',
].join(' ')}
style={labelLeftStyle}
>
{label}
</div>
</div>
)
}