Skip to content

Commit 1ac9450

Browse files
committed
Merge branch 'main' of https://github.com/recodehive/recode-website into blog/google-icon-doc
2 parents 4ad05ef + d2c3bd1 commit 1ac9450

4 files changed

Lines changed: 188 additions & 3 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import React, { useEffect, useState, useCallback, useRef } from "react";
2+
import styles from "./styles.module.css";
3+
4+
const MIN_REMAINING_MINUTES = 1;
5+
6+
interface Props {
7+
totalReadTime: number; // in minutes
8+
authorCardRef: React.RefObject<HTMLElement | null>;
9+
}
10+
11+
export default function ReadingTimeIndicator({
12+
totalReadTime,
13+
authorCardRef,
14+
}: Props): JSX.Element | null {
15+
const [visible, setVisible] = useState(false);
16+
const [remainingTime, setRemainingTime] = useState(totalReadTime);
17+
const rafRef = useRef<number | null>(null);
18+
19+
const computeState = useCallback(() => {
20+
const scrollY = window.scrollY;
21+
const winHeight = window.innerHeight;
22+
const docHeight = document.documentElement.scrollHeight;
23+
24+
const maxScroll = docHeight - winHeight;
25+
const pageScrollPercent = maxScroll > 0 ? (scrollY / maxScroll) * 100 : 0;
26+
27+
// Hide when the author card has entered the viewport,
28+
// or fall back to hiding at 90% page scroll when there is no author card
29+
let authorCardReached = false;
30+
if (authorCardRef.current) {
31+
const rect = authorCardRef.current.getBoundingClientRect();
32+
authorCardReached = rect.top < winHeight;
33+
} else {
34+
authorCardReached = pageScrollPercent >= 90;
35+
}
36+
37+
const shouldBeVisible = pageScrollPercent >= 15 && !authorCardReached;
38+
setVisible(shouldBeVisible);
39+
40+
// Calculate remaining time proportional to how far through the content the
41+
// user has scrolled. Use author card position when available; otherwise use
42+
// overall page scroll percentage as fallback.
43+
if (shouldBeVisible) {
44+
let readProgress = 0;
45+
if (authorCardRef.current) {
46+
const authorCardAbsTop =
47+
authorCardRef.current.getBoundingClientRect().top + scrollY;
48+
readProgress =
49+
authorCardAbsTop > 0
50+
? Math.max(0, Math.min(1, scrollY / authorCardAbsTop))
51+
: 0;
52+
} else {
53+
readProgress = Math.max(0, Math.min(1, pageScrollPercent / 90));
54+
}
55+
const remaining = Math.max(
56+
MIN_REMAINING_MINUTES,
57+
Math.ceil(totalReadTime * (1 - readProgress))
58+
);
59+
setRemainingTime(remaining);
60+
}
61+
}, [totalReadTime, authorCardRef]);
62+
63+
const handleScroll = useCallback(() => {
64+
// Throttle via requestAnimationFrame to avoid expensive layout reads on
65+
// every scroll event.
66+
if (rafRef.current !== null) return;
67+
rafRef.current = requestAnimationFrame(() => {
68+
rafRef.current = null;
69+
computeState();
70+
});
71+
}, [computeState]);
72+
73+
useEffect(() => {
74+
if (totalReadTime < MIN_REMAINING_MINUTES) return;
75+
window.addEventListener("scroll", handleScroll, { passive: true });
76+
computeState();
77+
return () => {
78+
window.removeEventListener("scroll", handleScroll);
79+
if (rafRef.current !== null) {
80+
cancelAnimationFrame(rafRef.current);
81+
rafRef.current = null;
82+
}
83+
};
84+
}, [handleScroll, computeState, totalReadTime]);
85+
86+
if (totalReadTime < MIN_REMAINING_MINUTES || !visible) return null;
87+
88+
const minLabel = remainingTime === MIN_REMAINING_MINUTES ? "min" : "mins";
89+
90+
return (
91+
<div
92+
className={styles.container}
93+
role="status"
94+
aria-label={`Estimated reading time: ${remainingTime} ${minLabel} remaining`}
95+
aria-live="polite"
96+
aria-atomic="true"
97+
>
98+
<svg
99+
xmlns="http://www.w3.org/2000/svg"
100+
viewBox="0 0 24 24"
101+
width="14"
102+
height="14"
103+
fill="none"
104+
stroke="currentColor"
105+
strokeWidth="2"
106+
strokeLinecap="round"
107+
strokeLinejoin="round"
108+
aria-hidden="true"
109+
className={styles.icon}
110+
>
111+
<circle cx="12" cy="12" r="10" />
112+
<polyline points="12 6 12 12 16 14" />
113+
</svg>
114+
<span>{remainingTime} {minLabel} remaining</span>
115+
</div>
116+
);
117+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
.container {
2+
position: fixed;
3+
bottom: 24px;
4+
right: 24px;
5+
z-index: 9999;
6+
display: flex;
7+
align-items: center;
8+
gap: 0.45rem;
9+
padding: 8px 14px;
10+
border-radius: 999px;
11+
background: #0d9488;
12+
color: #fff;
13+
font-size: 0.875rem;
14+
font-weight: 600;
15+
box-shadow: 0 4px 16px rgba(13, 148, 136, 0.35);
16+
animation: fadeInUp 0.25s ease-out;
17+
pointer-events: none;
18+
user-select: none;
19+
}
20+
21+
[data-theme="dark"] .container {
22+
background: #0f766e;
23+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
24+
}
25+
26+
.icon {
27+
flex-shrink: 0;
28+
opacity: 0.9;
29+
}
30+
31+
@keyframes fadeInUp {
32+
from {
33+
opacity: 0;
34+
transform: translateY(8px);
35+
}
36+
to {
37+
opacity: 1;
38+
transform: translateY(0);
39+
}
40+
}
41+
42+
@media (max-width: 640px) {
43+
.container {
44+
bottom: 16px;
45+
right: 16px;
46+
font-size: 0.8rem;
47+
padding: 6px 12px;
48+
}
49+
}

src/css/custom.css

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2146,7 +2146,18 @@ html[data-theme="dark"] {
21462146

21472147
.blog-post-page .markdown {
21482148
max-width: 100% !important;
2149-
line-height: 1.8 !important;
2149+
font-size: 1.125rem !important;
2150+
line-height: 2rem !important;
2151+
color: rgb(75, 85, 99) !important;
2152+
}
2153+
2154+
html[data-theme="dark"] .blog-post-page .markdown {
2155+
color: rgb(209, 213, 219) !important;
2156+
}
2157+
2158+
.blog-post-page .markdown p {
2159+
margin-top: 1.25rem !important;
2160+
margin-bottom: 0.5rem !important;
21502161
}
21512162

21522163
/* ── Large desktop (≥1400px) — wider content ── */

src/theme/BlogPostItem/Footer/index.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from "react";
1+
import React, { useRef } from "react";
22
import Link from "@docusaurus/Link";
33
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
44
import { useBlogPost } from "@docusaurus/plugin-content-blog/client";
@@ -8,6 +8,7 @@ import type { WrapperProps } from "@docusaurus/types";
88
import GiscusComments from "../../../components/giscus";
99
import SocialShare from "../../../components/SocialShare";
1010
import { getAuthorProfile } from "../../../utils/authors";
11+
import ReadingTimeIndicator from "../../../components/ReadingTimeIndicator";
1112

1213
import styles from "./styles.module.css";
1314

@@ -52,6 +53,7 @@ export default function BlogPostItemFooterWrapper(props: Props): JSX.Element {
5253
const { siteConfig } = useDocusaurusContext();
5354
const { metadata, isBlogPostPage } = useBlogPost();
5455
const primaryAuthor = metadata.authors?.[0];
56+
const authorCardRef = useRef<HTMLElement | null>(null);
5557

5658
const profile = primaryAuthor?.key
5759
? getAuthorProfile(primaryAuthor.key)
@@ -99,8 +101,14 @@ export default function BlogPostItemFooterWrapper(props: Props): JSX.Element {
99101
{isBlogPostPage && (
100102
<SocialShare permalink={metadata.permalink} title={metadata.title} />
101103
)}
104+
{isBlogPostPage && (
105+
<ReadingTimeIndicator
106+
totalReadTime={roundedReadTime}
107+
authorCardRef={authorCardRef}
108+
/>
109+
)}
102110
{showAuthorCard && (
103-
<section className={styles.authorCard} aria-label="Post author details">
111+
<section ref={authorCardRef} className={styles.authorCard} aria-label="Post author details">
104112
<div className={styles.authorBody}>
105113
<div className={styles.authorAvatarWrapper}>
106114
{authorAvatar ? (

0 commit comments

Comments
 (0)