Initial commit

This commit is contained in:
twotalesanimation
2026-06-11 10:46:09 +02:00
commit 81ad7e4ea9
223 changed files with 39530 additions and 0 deletions
+192
View File
@@ -0,0 +1,192 @@
'use client';
import React from 'react';
export interface WatchSegment {
startSec: number;
endSec: number;
watchedAt: string;
}
interface SegmentedProgressBarProps {
segments: WatchSegment[];
duration: number;
percent: number;
className?: string;
height?: 'sm' | 'md' | 'lg';
showTooltip?: boolean;
interactive?: boolean;
onSegmentClick?: (segment: WatchSegment, position: number) => void;
}
const heightPixels = {
sm: '4px',
md: '8px',
lg: '12px',
};
export function SegmentedProgressBar({
segments,
duration,
percent,
className = '',
height = 'md',
showTooltip = true,
interactive = false,
onSegmentClick,
}: SegmentedProgressBarProps) {
const [tooltipPos, setTooltipPos] = React.useState<{ x: number; time: string } | null>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
// Normalize segments: merge overlapping ranges
const normalizedSegments = React.useMemo(() => {
if (segments.length === 0) return [];
const sorted = [...segments].sort((a, b) => a.startSec - b.startSec);
const merged: WatchSegment[] = [];
for (const seg of sorted) {
if (merged.length === 0) {
merged.push({ ...seg });
} else {
const last = merged[merged.length - 1];
// Check for overlap or adjacency (within 0.5s)
if (seg.startSec <= last.endSec + 0.5) {
// Merge
last.endSec = Math.max(last.endSec, seg.endSec);
} else {
// Gap, add new segment
merged.push({ ...seg });
}
}
}
return merged;
}, [segments]);
const handleMouseMove = React.useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!showTooltip || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, x / rect.width));
const time = Math.round(percentage * duration);
setTooltipPos({
x,
time: formatTime(time),
});
},
[duration, showTooltip]
);
const handleMouseLeave = () => {
setTooltipPos(null);
};
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!interactive || !onSegmentClick || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, x / rect.width));
const position = Math.round(percentage * duration);
// Find which segment was clicked
for (const segment of normalizedSegments) {
if (position >= segment.startSec && position <= segment.endSec) {
onSegmentClick(segment, position);
break;
}
}
};
const barHeight = heightPixels[height];
return (
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '4px' }} className={className}>
{/* Main progress bar container */}
<div
ref={containerRef}
style={{
position: 'relative',
width: '100%',
height: barHeight,
backgroundColor: '#d1d5db',
borderRadius: '9999px',
overflow: 'hidden',
cursor: interactive ? 'pointer' : 'default',
}}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
>
{/* Watched segments */}
{normalizedSegments.map((segment, idx) => {
const startPercent = (segment.startSec / duration) * 100;
const endPercent = (segment.endSec / duration) * 100;
const width = endPercent - startPercent;
return (
<div
key={idx}
style={{
position: 'absolute',
top: 0,
height: '100%',
backgroundColor: '#ff6900',
left: `${Math.max(0, startPercent)}%`,
width: `${Math.max(0, Math.min(100, width))}%`,
transition: 'background-color 200ms',
}}
title={`${formatTime(segment.startSec)} - ${formatTime(segment.endSec)}`}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#2563eb';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#3b82f6';
}}
/>
);
})}
{/* Current progress line */}
{percent > 0 && (
<div
style={{
position: 'absolute',
top: 0,
bottom: 0,
width: '2px',
backgroundColor: '#ef4444',
opacity: 0.8,
pointerEvents: 'none',
zIndex: 10,
left: `${Math.max(0, Math.min(100, percent))}%`,
transform: 'translateX(-50%)',
}}
/>
)}
</div>
{/* Tooltip text - only show if showTooltip is true */}
{tooltipPos && showTooltip && (
<div style={{ fontSize: '12px', color: '#6b7280', whiteSpace: 'nowrap' }}>
{tooltipPos.time} / {formatTime(duration)}
</div>
)}
</div>
);
}
function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) {
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
return `${m}:${String(s).padStart(2, '0')}`;
}