mirror of
https://github.com/mroxso/zelo-news.git
synced 2026-06-05 10:01:22 +02:00
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:
73
src/components/ArticleProgressBar.tsx
Normal file
73
src/components/ArticleProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/components/ReadingTime.tsx
Normal file
23
src/components/ReadingTime.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/lib/calculateReadingTime.test.ts
Normal file
51
src/lib/calculateReadingTime.test.ts
Normal 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)
|
||||

|
||||
> 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);
|
||||
});
|
||||
});
|
||||
40
src/lib/calculateReadingTime.ts
Normal file
40
src/lib/calculateReadingTime.ts
Normal 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);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user