Add reading time display and sticky progress bar to article pages (#10)

* Initial plan

* Add reading time and sticky progress bar to article pages

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>

* Update ArticleProgressBar to adjust sticky position to top-16

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
Co-authored-by: highperfocused <highperfocused@pm.me>
This commit is contained in:
Copilot
2025-10-06 18:15:01 +02:00
committed by GitHub
parent c0db15376c
commit 6b8874d855
5 changed files with 199 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
import { useEffect, useState, useRef } from 'react';
import { cn } from '@/lib/utils';
interface ArticleProgressBarProps {
className?: string;
}
/**
* Sticky progress bar that shows reading progress through the article
* Positioned at the top of viewport and fills from 0% to 100% as user scrolls
*/
export function ArticleProgressBar({ className }: ArticleProgressBarProps) {
const [progress, setProgress] = useState(0);
const progressRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const calculateProgress = () => {
// Get the article content element (the main scrollable content)
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
const scrollTop = window.scrollY;
// Calculate how far through the document we've scrolled
// Subtract window height to account for viewport
const scrollableHeight = documentHeight - windowHeight;
if (scrollableHeight <= 0) {
setProgress(100);
return;
}
const scrollPercentage = (scrollTop / scrollableHeight) * 100;
// Clamp between 0 and 100
const clampedProgress = Math.min(100, Math.max(0, scrollPercentage));
setProgress(clampedProgress);
};
// Calculate initial progress
calculateProgress();
// Update on scroll - use passive listener for better performance
window.addEventListener('scroll', calculateProgress, { passive: true });
// Update on resize in case content height changes
window.addEventListener('resize', calculateProgress, { passive: true });
return () => {
window.removeEventListener('scroll', calculateProgress);
window.removeEventListener('resize', calculateProgress);
};
}, []);
return (
<div
ref={progressRef}
className={cn(
'sticky top-16 left-0 right-0 z-40 h-1 bg-muted',
className
)}
role="progressbar"
aria-valuenow={Math.round(progress)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`You have read ${Math.round(progress)}% of this article`}
>
<div
className="h-full bg-primary transition-all duration-150 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { Clock } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ReadingTimeProps {
minutes: number;
className?: string;
}
/**
* Displays estimated reading time for an article
* Format: "🕒 X min read"
*/
export function ReadingTime({ minutes, className }: ReadingTimeProps) {
return (
<div
className={cn('flex items-center gap-2 text-sm text-muted-foreground', className)}
aria-label={`Estimated reading time: ${minutes} minute${minutes !== 1 ? 's' : ''}`}
>
<Clock className="h-4 w-4" aria-hidden="true" />
<span>{minutes} min read</span>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { calculateReadingTime } from './calculateReadingTime';
describe('calculateReadingTime', () => {
it('should return 1 minute for empty content', () => {
expect(calculateReadingTime('')).toBe(1);
expect(calculateReadingTime(' ')).toBe(1);
});
it('should calculate reading time for plain text', () => {
// 200 words should take 1 minute at default 200 wpm
const words = Array(200).fill('word').join(' ');
expect(calculateReadingTime(words)).toBe(1);
// 250 words should take 2 minutes (rounded up)
const moreWords = Array(250).fill('word').join(' ');
expect(calculateReadingTime(moreWords)).toBe(2);
});
it('should strip markdown formatting', () => {
const markdown = `
# Heading
This is **bold** and *italic* text.
\`\`\`javascript
const code = 'should not count';
\`\`\`
[Link text](https://example.com)
![Image alt](https://example.com/image.png)
> Blockquote text
- List item
`;
// Should count: Heading, This, is, bold, and, italic, text, Link, text, Blockquote, text, List, item
// Total: 13 words, at 200 wpm = 1 minute
const result = calculateReadingTime(markdown);
expect(result).toBeGreaterThan(0);
});
it('should use custom words per minute', () => {
const words = Array(300).fill('word').join(' ');
// At 300 wpm, 300 words should take 1 minute
expect(calculateReadingTime(words, 300)).toBe(1);
// At 100 wpm, 300 words should take 3 minutes
expect(calculateReadingTime(words, 100)).toBe(3);
});
it('should return at least 1 minute for very short content', () => {
expect(calculateReadingTime('Hello world')).toBe(1);
expect(calculateReadingTime('Just a few words here')).toBe(1);
});
});

View File

@@ -0,0 +1,40 @@
/**
* Calculate estimated reading time for text content
* @param content - The text content to analyze (markdown or plain text)
* @param wordsPerMinute - Average reading speed (default: 200 words per minute)
* @returns Estimated reading time in minutes
*/
export function calculateReadingTime(content: string, wordsPerMinute: number = 200): number {
if (!content || content.trim().length === 0) {
return 1; // Minimum 1 minute for empty content
}
// Remove markdown formatting to get accurate word count
// Remove code blocks
let cleanContent = content.replace(/```[\s\S]*?```/g, '');
// Remove inline code
cleanContent = cleanContent.replace(/`[^`]*`/g, '');
// Remove links
cleanContent = cleanContent.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
// Remove images
cleanContent = cleanContent.replace(/!\[([^\]]*)\]\([^)]+\)/g, '');
// Remove headings markup
cleanContent = cleanContent.replace(/#+\s/g, '');
// Remove bold/italic
cleanContent = cleanContent.replace(/[*_]{1,3}/g, '');
// Remove blockquote markers
cleanContent = cleanContent.replace(/^>\s/gm, '');
// Remove list markers
cleanContent = cleanContent.replace(/^[-*+]\s/gm, '');
cleanContent = cleanContent.replace(/^\d+\.\s/gm, '');
// Count words (split by whitespace)
const words = cleanContent.trim().split(/\s+/).filter(word => word.length > 0);
const wordCount = words.length;
// Calculate reading time in minutes, rounded up
const minutes = Math.ceil(wordCount / wordsPerMinute);
// Return at least 1 minute
return Math.max(1, minutes);
}

View File

@@ -8,6 +8,8 @@ import { MarkdownContent } from '@/components/MarkdownContent';
import { CommentsSection } from '@/components/comments/CommentsSection';
import { ZapButton } from '@/components/ZapButton';
import { BookmarkButton } from '@/components/BookmarkButton';
import { ReadingTime } from '@/components/ReadingTime';
import { ArticleProgressBar } from '@/components/ArticleProgressBar';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
@@ -15,6 +17,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Separator } from '@/components/ui/separator';
import { Calendar, Heart, Edit, ArrowLeft } from 'lucide-react';
import { genUserName } from '@/lib/genUserName';
import { calculateReadingTime } from '@/lib/calculateReadingTime';
import NotFound from '@/pages/NotFound';
export default function BlogPostPage() {
@@ -89,6 +92,9 @@ export default function BlogPostPage() {
? new Date(parseInt(publishedAt) * 1000)
: new Date(post.created_at * 1000);
// Calculate reading time
const readingTime = calculateReadingTime(post.content);
const handleReact = () => {
if (!user) return;
if (hasReacted) return;
@@ -97,6 +103,9 @@ export default function BlogPostPage() {
return (
<div className="min-h-screen">
{/* Sticky progress bar */}
<ArticleProgressBar />
<article className="container max-w-4xl py-8 px-4 sm:px-6 lg:px-8">
{/* Back button */}
<Button
@@ -121,6 +130,9 @@ export default function BlogPostPage() {
</p>
)}
{/* Reading time */}
<ReadingTime minutes={readingTime} />
{/* Author info and metadata */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-3 hover:opacity-80 transition-opacity">