diff --git a/src/components/ArticleProgressBar.tsx b/src/components/ArticleProgressBar.tsx new file mode 100644 index 0000000..81b7162 --- /dev/null +++ b/src/components/ArticleProgressBar.tsx @@ -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(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 ( +
+
+
+ ); +} diff --git a/src/components/ReadingTime.tsx b/src/components/ReadingTime.tsx new file mode 100644 index 0000000..11f5d9e --- /dev/null +++ b/src/components/ReadingTime.tsx @@ -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 ( +
+
+ ); +} diff --git a/src/lib/calculateReadingTime.test.ts b/src/lib/calculateReadingTime.test.ts new file mode 100644 index 0000000..6501b17 --- /dev/null +++ b/src/lib/calculateReadingTime.test.ts @@ -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); + }); +}); diff --git a/src/lib/calculateReadingTime.ts b/src/lib/calculateReadingTime.ts new file mode 100644 index 0000000..b2c1d45 --- /dev/null +++ b/src/lib/calculateReadingTime.ts @@ -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); +} diff --git a/src/pages/BlogPostPage.tsx b/src/pages/BlogPostPage.tsx index 83fdce5..398d5ec 100644 --- a/src/pages/BlogPostPage.tsx +++ b/src/pages/BlogPostPage.tsx @@ -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 (
+ {/* Sticky progress bar */} + +
{/* Back button */}