diff --git a/frontend/src/components/CommentBox.tsx b/frontend/src/components/CommentBox.tsx index c80c70bb..f7f71379 100644 --- a/frontend/src/components/CommentBox.tsx +++ b/frontend/src/components/CommentBox.tsx @@ -37,9 +37,10 @@ export type CommentsType = { type CommentBoxProps = { comment: CommentsType; + onTimestampClick?: (seconds: number) => void; }; -const CommentBox = ({ comment }: CommentBoxProps) => { +const CommentBox = ({ comment, onTimestampClick }: CommentBoxProps) => { const [showSubComments, setShowSubComments] = useState(false); const hasSubComments = @@ -51,7 +52,7 @@ const CommentBox = ({ comment }: CommentBoxProps) => { {comment.comment_author}

- {comment.comment_text} + {comment.comment_text}

@@ -95,7 +96,7 @@ const CommentBox = ({ comment }: CommentBoxProps) => { comment.comment_replies?.map(comment => { return ( - + ); })} diff --git a/frontend/src/components/Linkify.tsx b/frontend/src/components/Linkify.tsx index a9d1d839..57dd92cb 100644 --- a/frontend/src/components/Linkify.tsx +++ b/frontend/src/components/Linkify.tsx @@ -3,17 +3,61 @@ import DOMPurify from 'dompurify'; type LinkifyProps = { children: string; ignoreLineBreak?: boolean; + onTimestampClick?: (seconds: number) => void; }; // source: https://www.js-craft.io/blog/react-detect-url-text-convert-link/ -const Linkify = ({ children, ignoreLineBreak = false }: LinkifyProps) => { +const Linkify = ({ children, ignoreLineBreak = false, onTimestampClick }: LinkifyProps) => { const isUrl = (word: string) => { const urlPattern = /(https?:\/\/[^\s]+)/g; return word.match(urlPattern); }; + const isTimestamp = (word: string) => { + const timestampPattern = /^(\d{1,}:)?(\d{1,2}):(\d{1,2})$/; + return word.match(timestampPattern); + }; + + const parseTimestamp = (timestamp: string): number => { + const parts = timestamp.split(':').map(part => parseInt(part, 10)); + + if (parts.length === 2) { + // MM:SS format + const [minutes, seconds] = parts; + return minutes * 60 + seconds; + } + + if (parts.length === 3) { + // HH:MM:SS format + const [hours, minutes, seconds] = parts; + return hours * 3600 + minutes * 60 + seconds; + } + + return 0; + }; + const addMarkup = (word: string) => { - return isUrl(word) ? `${word}` : word; + if (isUrl(word)) { + return `${word}`; + } + + if (isTimestamp(word) && onTimestampClick) { + const seconds = parseTimestamp(word); + return `${word}`; + } + + return word; + }; + + const handleClick = (event: React.MouseEvent) => { + const target = event.target as HTMLElement; + if (target.classList.contains('timestamp-link') && onTimestampClick) { + const timestamp = target.getAttribute('data-timestamp'); + if (timestamp) { + const seconds = parseInt(timestamp, 10); + onTimestampClick(seconds); + } + } }; let workingText = children; @@ -26,9 +70,11 @@ const Linkify = ({ children, ignoreLineBreak = false }: LinkifyProps) => { const formatedWords = words.map(w => addMarkup(w)); - const html = DOMPurify.sanitize(formatedWords.join(' ')); + const html = DOMPurify.sanitize(formatedWords.join(' '), { + ADD_ATTR: ['target'], + }); - return ; + return ; }; export default Linkify; diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx index de8fc4bb..d6c66c15 100644 --- a/frontend/src/components/VideoPlayer.tsx +++ b/frontend/src/components/VideoPlayer.tsx @@ -111,6 +111,8 @@ type VideoPlayerProps = { autoplay?: boolean; onWatchStateChanged?: (status: boolean) => void; onVideoEnd?: () => void; + seekToTimestamp?: number; + setSeekToTimestamp?: (timestamp: number | undefined) => void; }; const VideoPlayer = ({ @@ -120,9 +122,30 @@ const VideoPlayer = ({ autoplay = false, onWatchStateChanged, onVideoEnd, + seekToTimestamp, + setSeekToTimestamp, }: VideoPlayerProps) => { const videoRef = useRef(null); + useEffect(() => { + if (seekToTimestamp === undefined || !videoRef.current) { + return; + } + + const videoDuration = videoRef.current.duration; + if (isNaN(videoDuration) || seekToTimestamp > videoDuration) { + return; + } + + videoRef.current.currentTime = seekToTimestamp; + + if (videoRef.current.paused) { + videoRef.current.play(); + } + if (setSeekToTimestamp) setSeekToTimestamp(undefined); + window.scroll(0, 0); + }, [seekToTimestamp]); + const [searchParams] = useSearchParams(); const searchParamVideoProgress = searchParams.get('t'); diff --git a/frontend/src/pages/Video.tsx b/frontend/src/pages/Video.tsx index 68929f38..93c211b0 100644 --- a/frontend/src/pages/Video.tsx +++ b/frontend/src/pages/Video.tsx @@ -109,6 +109,7 @@ const Video = () => { const { userConfig } = useUserConfigStore(); const [videoEnded, setVideoEnded] = useState(false); + const [seekToTimestamp, setSeekToTimestamp] = useState(); const [playlistAutoplay, setPlaylistAutoplay] = useState( localStorage.getItem('playlistAutoplay') === 'true', ); @@ -135,6 +136,10 @@ const Video = () => { const { data: customPlaylistsResponseData } = customPlaylistsResponse ?? {}; const { data: commentsResponseData } = commentsResponse ?? {}; + const handleTimestampClick = (seconds: number) => { + setSeekToTimestamp(seconds); + }; + useEffect(() => { (async () => { if (refreshVideoList || videoId !== videoResponseData?.youtube_id) { @@ -224,6 +229,8 @@ const Video = () => { video={video} sponsorBlock={sponsorBlock} autoplay={playlistAutoplay} + seekToTimestamp={seekToTimestamp} + setSeekToTimestamp={setSeekToTimestamp} onWatchStateChanged={() => { setRefreshVideoList(true); }} @@ -492,7 +499,7 @@ const Video = () => { id={descriptionExpanded ? 'text-expand-expanded' : 'text-expand'} className="description-text" > - {video.description} + {video.description}