diff --git a/src/components/ReadingTimeIndicator/index.tsx b/src/components/ReadingTimeIndicator/index.tsx new file mode 100644 index 00000000..507a7945 --- /dev/null +++ b/src/components/ReadingTimeIndicator/index.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useState, useCallback, useRef } from "react"; +import styles from "./styles.module.css"; + +const MIN_REMAINING_MINUTES = 1; + +interface Props { + totalReadTime: number; // in minutes + authorCardRef: React.RefObject; +} + +export default function ReadingTimeIndicator({ + totalReadTime, + authorCardRef, +}: Props): JSX.Element | null { + const [visible, setVisible] = useState(false); + const [remainingTime, setRemainingTime] = useState(totalReadTime); + const rafRef = useRef(null); + + const computeState = useCallback(() => { + const scrollY = window.scrollY; + const winHeight = window.innerHeight; + const docHeight = document.documentElement.scrollHeight; + + const maxScroll = docHeight - winHeight; + const pageScrollPercent = maxScroll > 0 ? (scrollY / maxScroll) * 100 : 0; + + // Hide when the author card has entered the viewport, + // or fall back to hiding at 90% page scroll when there is no author card + let authorCardReached = false; + if (authorCardRef.current) { + const rect = authorCardRef.current.getBoundingClientRect(); + authorCardReached = rect.top < winHeight; + } else { + authorCardReached = pageScrollPercent >= 90; + } + + const shouldBeVisible = pageScrollPercent >= 15 && !authorCardReached; + setVisible(shouldBeVisible); + + // Calculate remaining time proportional to how far through the content the + // user has scrolled. Use author card position when available; otherwise use + // overall page scroll percentage as fallback. + if (shouldBeVisible) { + let readProgress = 0; + if (authorCardRef.current) { + const authorCardAbsTop = + authorCardRef.current.getBoundingClientRect().top + scrollY; + readProgress = + authorCardAbsTop > 0 + ? Math.max(0, Math.min(1, scrollY / authorCardAbsTop)) + : 0; + } else { + readProgress = Math.max(0, Math.min(1, pageScrollPercent / 90)); + } + const remaining = Math.max( + MIN_REMAINING_MINUTES, + Math.ceil(totalReadTime * (1 - readProgress)) + ); + setRemainingTime(remaining); + } + }, [totalReadTime, authorCardRef]); + + const handleScroll = useCallback(() => { + // Throttle via requestAnimationFrame to avoid expensive layout reads on + // every scroll event. + if (rafRef.current !== null) return; + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + computeState(); + }); + }, [computeState]); + + useEffect(() => { + if (totalReadTime < MIN_REMAINING_MINUTES) return; + window.addEventListener("scroll", handleScroll, { passive: true }); + computeState(); + return () => { + window.removeEventListener("scroll", handleScroll); + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, [handleScroll, computeState, totalReadTime]); + + if (totalReadTime < MIN_REMAINING_MINUTES || !visible) return null; + + const minLabel = remainingTime === MIN_REMAINING_MINUTES ? "min" : "mins"; + + return ( +
+ + {remainingTime} {minLabel} remaining +
+ ); +} diff --git a/src/components/ReadingTimeIndicator/styles.module.css b/src/components/ReadingTimeIndicator/styles.module.css new file mode 100644 index 00000000..acf3dd5d --- /dev/null +++ b/src/components/ReadingTimeIndicator/styles.module.css @@ -0,0 +1,49 @@ +.container { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 9999; + display: flex; + align-items: center; + gap: 0.45rem; + padding: 8px 14px; + border-radius: 999px; + background: #0d9488; + color: #fff; + font-size: 0.875rem; + font-weight: 600; + box-shadow: 0 4px 16px rgba(13, 148, 136, 0.35); + animation: fadeInUp 0.25s ease-out; + pointer-events: none; + user-select: none; +} + +[data-theme="dark"] .container { + background: #0f766e; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); +} + +.icon { + flex-shrink: 0; + opacity: 0.9; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 640px) { + .container { + bottom: 16px; + right: 16px; + font-size: 0.8rem; + padding: 6px 12px; + } +} diff --git a/src/theme/BlogPostItem/Footer/index.tsx b/src/theme/BlogPostItem/Footer/index.tsx index c0c4685b..5c0f04ca 100644 --- a/src/theme/BlogPostItem/Footer/index.tsx +++ b/src/theme/BlogPostItem/Footer/index.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useRef } from "react"; import Link from "@docusaurus/Link"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { useBlogPost } from "@docusaurus/plugin-content-blog/client"; @@ -8,6 +8,7 @@ import type { WrapperProps } from "@docusaurus/types"; import GiscusComments from "../../../components/giscus"; import SocialShare from "../../../components/SocialShare"; import { getAuthorProfile } from "../../../utils/authors"; +import ReadingTimeIndicator from "../../../components/ReadingTimeIndicator"; import styles from "./styles.module.css"; @@ -52,6 +53,7 @@ export default function BlogPostItemFooterWrapper(props: Props): JSX.Element { const { siteConfig } = useDocusaurusContext(); const { metadata, isBlogPostPage } = useBlogPost(); const primaryAuthor = metadata.authors?.[0]; + const authorCardRef = useRef(null); const profile = primaryAuthor?.key ? getAuthorProfile(primaryAuthor.key) @@ -99,8 +101,14 @@ export default function BlogPostItemFooterWrapper(props: Props): JSX.Element { {isBlogPostPage && ( )} + {isBlogPostPage && ( + + )} {showAuthorCard && ( -
+
{authorAvatar ? (