Merge pull request #24 from damjan1996/auto-claude/005-add-reading-time-to-blog-posts

auto-claude: 005-add-reading-time-to-blog-posts
This commit is contained in:
Damjan Savic
2026-01-25 19:51:44 +01:00
committed by GitHub
3 changed files with 85 additions and 27 deletions
+9 -1
View File
@@ -5,8 +5,9 @@ import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { BreadcrumbJsonLd, ArticleJsonLd } from '@/components/seo'; import { BreadcrumbJsonLd, ArticleJsonLd } from '@/components/seo';
import { getBlogPostBySlug, getBlogPostSlugs } from '@/lib/blog'; import { getBlogPostBySlug, getBlogPostSlugs, calculateReadingTime } from '@/lib/blog';
import { renderMarkdown } from '@/lib/markdown'; import { renderMarkdown } from '@/lib/markdown';
import { Clock } from 'lucide-react';
type Props = { type Props = {
params: Promise<{ locale: string; slug: string }>; params: Promise<{ locale: string; slug: string }>;
@@ -1315,6 +1316,9 @@ export default async function BlogPostPage({ params }: Props) {
{ name: post.title, url: `/${locale}/blog/${slug}` }, { name: post.title, url: `/${locale}/blog/${slug}` },
]; ];
// Calculate reading time
const readingTime = post.content ? calculateReadingTime(post.content) : 1;
return ( return (
<main className="min-h-screen py-16 sm:py-20 lg:py-24"> <main className="min-h-screen py-16 sm:py-20 lg:py-24">
<BreadcrumbJsonLd items={breadcrumbItems} locale={locale as Locale} /> <BreadcrumbJsonLd items={breadcrumbItems} locale={locale as Locale} />
@@ -1343,6 +1347,10 @@ export default async function BlogPostPage({ params }: Props) {
<header className="mb-12"> <header className="mb-12">
<div className="flex items-center gap-4 mb-6 text-zinc-500"> <div className="flex items-center gap-4 mb-6 text-zinc-500">
<time dateTime={post.date}>{getFormattedDate(post.date)}</time> <time dateTime={post.date}>{getFormattedDate(post.date)}</time>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4" />
<span>{readingTime} min read</span>
</div>
<span className="px-3 py-1 bg-zinc-800 rounded-full text-sm"> <span className="px-3 py-1 bg-zinc-800 rounded-full text-sm">
{post.category} {post.category}
</span> </span>
+46 -26
View File
@@ -1,19 +1,17 @@
import { setRequestLocale } from 'next-intl/server'; import { setRequestLocale } from 'next-intl/server';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { ArrowRight } from 'lucide-react'; import { ArrowRight, Calendar, Tag, ExternalLink, ChevronLeft, ChevronRight, Clock } from 'lucide-react';
import Link from 'next/link';
import Image from 'next/image';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getAllBlogPosts, BlogPost } from '@/lib/blog'; import { getAllBlogPosts, BlogPost } from '@/lib/blog';
<<<<<<< HEAD
import { BlogList } from '@/components/blog/BlogList';
=======
import { legacyBlogPosts } from '@/data/legacyBlogPosts'; import { legacyBlogPosts } from '@/data/legacyBlogPosts';
const POSTS_PER_PAGE = 12; const POSTS_PER_PAGE = 12;
>>>>>>> origin/master
type Props = { type Props = {
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; searchParams: Promise<{ page?: string }>;
}; };
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com'; const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com';
@@ -84,8 +82,6 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
}; };
} }
<<<<<<< HEAD
=======
function getFormattedDate(date: string, locale: string) { function getFormattedDate(date: string, locale: string) {
const languageMap: Record<string, string> = { const languageMap: Record<string, string> = {
de: 'de-DE', de: 'de-DE',
@@ -156,6 +152,12 @@ function BlogPostCard({ post, locale }: { post: BlogPost; locale: string }) {
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
<span>{getFormattedDate(post.date, locale)}</span> <span>{getFormattedDate(post.date, locale)}</span>
</div> </div>
{post.readingTime && (
<div className="flex items-center gap-2 text-sm">
<Clock className="h-4 w-4" />
<span>{post.readingTime} min read</span>
</div>
)}
{post.tags && post.tags.length > 0 && ( {post.tags && post.tags.length > 0 && (
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Tag className="h-4 w-4" /> <Tag className="h-4 w-4" />
@@ -338,11 +340,10 @@ function Pagination({
</nav> </nav>
); );
} }
>>>>>>> origin/master
export default async function BlogPage({ params, searchParams }: Props) { export default async function BlogPage({ params, searchParams }: Props) {
const { locale } = await params; const { locale } = await params;
const resolvedSearchParams = await searchParams; const { page } = await searchParams;
setRequestLocale(locale); setRequestLocale(locale);
const t = await getTranslations('blog'); const t = await getTranslations('blog');
@@ -360,9 +361,14 @@ export default async function BlogPage({ params, searchParams }: Props) {
...legacyPosts, ...legacyPosts,
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); ].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
// Extract search and category from URL params // Pagination
const initialSearch = typeof resolvedSearchParams.search === 'string' ? resolvedSearchParams.search : ''; const currentPage = Math.max(1, parseInt(page || '1', 10) || 1);
const initialCategory = typeof resolvedSearchParams.category === 'string' ? resolvedSearchParams.category : null; const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE);
const validPage = Math.min(currentPage, totalPages || 1);
const startIndex = (validPage - 1) * POSTS_PER_PAGE;
const endIndex = startIndex + POSTS_PER_PAGE;
const posts = allPosts.slice(startIndex, endIndex);
return ( return (
<main className="min-h-screen"> <main className="min-h-screen">
@@ -379,23 +385,37 @@ export default async function BlogPage({ params, searchParams }: Props) {
<p className="text-lg sm:text-xl text-zinc-400 max-w-2xl"> <p className="text-lg sm:text-xl text-zinc-400 max-w-2xl">
{t('meta.header.subtitle')} {t('meta.header.subtitle')}
</p> </p>
{/* Post count */}
<p className="text-sm text-zinc-500 mt-4">
{t('ui.pagination.showing', {
start: startIndex + 1,
end: Math.min(endIndex, allPosts.length),
total: allPosts.length,
})}
</p>
</div> </div>
{/* Blog List with Search and Filters */} {/* Blog Posts Grid */}
<BlogList <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8">
posts={allPosts} {posts.map((post) => (
<BlogPostCard key={post.slug} post={post} locale={locale} />
))}
</div>
{/* Pagination */}
<Pagination
currentPage={validPage}
totalPages={totalPages}
locale={locale} locale={locale}
initialSearch={initialSearch} t={(key) => t(key)}
initialCategory={initialCategory}
translations={{
searchPlaceholder: t('ui.search.placeholder'),
showingText: (start: number, end: number, total: number) =>
t('ui.pagination.showing', { start, end, total }),
noPostsText: t('ui.errors.posts'),
paginationPrevious: t('ui.pagination.previous'),
paginationNext: t('ui.pagination.next'),
}}
/> />
{/* Empty State */}
{posts.length === 0 && (
<div className="text-center py-12">
<p className="text-zinc-400">{t('ui.errors.posts')}</p>
</div>
)}
</div> </div>
</main> </main>
); );
+30
View File
@@ -28,6 +28,7 @@ export interface BlogPost {
tags?: string[]; tags?: string[];
/** Vollständiger Markdown-Inhalt */ /** Vollständiger Markdown-Inhalt */
content?: string; content?: string;
readingTime?: number;
} }
// Verzeichnis der Blog-Beiträge // Verzeichnis der Blog-Beiträge
@@ -99,6 +100,31 @@ function detectCategory(filename: string, content: string): string {
return 'Technologie'; return 'Technologie';
} }
/**
* Berechnet die geschätzte Lesezeit für einen Blog-Post
* @param content - Der Markdown-Inhalt des Posts
* @returns Lesezeit in Minuten (mindestens 1)
*/
export function calculateReadingTime(content: string): number {
// Remove markdown syntax for more accurate word count
const text = content
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/`[^`]*`/g, '') // Remove inline code
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images
.replace(/\[.*?\]\(.*?\)/g, '') // Remove links
.replace(/[#*_~]/g, '') // Remove markdown formatting
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
// Count words
const words = text.split(/\s+/).filter(word => word.length > 0).length;
// Calculate reading time (average reading speed: 200 words per minute)
const minutes = Math.ceil(words / 200);
return Math.max(1, minutes); // Minimum 1 minute
}
/** /**
* Parst einen Markdown-Blog-Post und extrahiert alle Metadaten * Parst einen Markdown-Blog-Post und extrahiert alle Metadaten
* Extrahiert Titel, Meta-Description, Keywords, Kategorie und generiert Slug * Extrahiert Titel, Meta-Description, Keywords, Kategorie und generiert Slug
@@ -149,6 +175,9 @@ function parseMarkdownPost(filename: string, content: string): BlogPost | null {
// Cover image path // Cover image path
const coverImage = `/images/posts/${slug}/cover.jpg`; const coverImage = `/images/posts/${slug}/cover.jpg`;
// Calculate reading time
const readingTime = calculateReadingTime(content);
return { return {
slug, slug,
title, title,
@@ -158,6 +187,7 @@ function parseMarkdownPost(filename: string, content: string): BlogPost | null {
category, category,
tags, tags,
content, content,
readingTime,
}; };
} catch (error) { } catch (error) {
console.error(`Error parsing ${filename}:`, error); console.error(`Error parsing ${filename}:`, error);