feat(blog): add blog feature with layout, list component and i18n support
- Create BlogLayout for consistent blog page structure - Implement BlogList component with responsive design and line-clamp - Add blog navigation to header with proper routing - Include i18n support for both English and Chinese - Add sample blog pages with mock data
This commit is contained in:
124
src/components/BlogList.tsx
Normal file
124
src/components/BlogList.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import { useTranslations, type Lang } from '../i18n/utils';
|
||||
|
||||
/**
|
||||
* Blog post interface definition
|
||||
*/
|
||||
interface BlogPost {
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
slug: string;
|
||||
tags: string[];
|
||||
date: string;
|
||||
readTime: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props interface for BlogList component
|
||||
*/
|
||||
interface BlogListProps {
|
||||
posts: BlogPost[];
|
||||
lang: Lang;
|
||||
baseUrl?: string; // Base URL for blog posts, defaults to '/blog/posts/'
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable blog list component that displays blog posts in a grid layout
|
||||
* @param posts - Array of blog posts to display
|
||||
* @param lang - Current language for internationalization
|
||||
* @param baseUrl - Base URL for blog post links
|
||||
*/
|
||||
export default function BlogList({ posts, lang, baseUrl = '/blog/posts/' }: BlogListProps) {
|
||||
const t = useTranslations(lang);
|
||||
|
||||
// Adjust base URL for Chinese language
|
||||
const postBaseUrl = lang === 'zh' ? '/zh/blog/posts/' : baseUrl;
|
||||
|
||||
// Get localized "Read More" text
|
||||
const readMoreText = lang === 'zh' ? '阅读更多' : 'Read More';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{posts.map((post, index) => (
|
||||
<article key={post.slug || index} className="group">
|
||||
<div className="bg-card/50 backdrop-blur-sm rounded-2xl overflow-hidden border border-border hover:border-purple-500/50 transition-all duration-300 hover:transform hover:scale-[1.02]">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
{/* Featured Image */}
|
||||
<div className="relative overflow-hidden md:w-80 md:flex-shrink-0">
|
||||
<img
|
||||
src={post.image}
|
||||
alt={post.title}
|
||||
className="w-full h-48 md:h-full object-cover group-hover:scale-110 transition-transform duration-300"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t md:bg-gradient-to-r from-background/50 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 flex-1 flex flex-col justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-card-foreground mb-3 group-hover:text-purple-500 transition-colors duration-200">
|
||||
<a href={`${postBaseUrl}${post.slug}`}>
|
||||
{post.title}
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground mb-4 line-clamp-3">
|
||||
{post.description}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{post.tags.map((tag, tagIndex) => (
|
||||
<span
|
||||
key={`${tag}-${tagIndex}`}
|
||||
className="px-2 py-1 text-xs bg-muted text-muted-foreground rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta Info */}
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="flex items-center">
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 002 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
{post.date}
|
||||
</span>
|
||||
<span>{post.readTime}</span>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`${postBaseUrl}${post.slug}`}
|
||||
className="text-purple-500 hover:text-purple-400 font-medium flex items-center group"
|
||||
>
|
||||
{readMoreText}
|
||||
<svg className="w-4 h-4 ml-1 group-hover:translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS styles for line clamping (to be included in global styles)
|
||||
*/
|
||||
export const blogListStyles = `
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
@@ -1,6 +1,6 @@
|
||||
import ThemeToggle from "./ui/theme-toggle";
|
||||
import { personalInfo } from "@/lib/data";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import ThemeToggle from "./ui/theme-toggle";
|
||||
import { useTranslations, getLocalizedPath, type Lang } from "@/i18n/utils";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Menu, X } from "lucide-react";
|
||||
@@ -45,10 +45,11 @@ export default function GlassHeader({ lang }: GlassHeaderProps) {
|
||||
{ key: 'nav.about', icon: '👨💻 ', sectionId: 'about' },
|
||||
{ key: 'nav.services', icon: '🛠️ ', sectionId: 'services' },
|
||||
{ key: 'nav.projects', icon: '🚀 ', sectionId: 'projects' },
|
||||
{ key: 'nav.blog', icon: '📝 ', href: getLocalizedPath('/blog', lang) },
|
||||
].map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
href={`#${item.sectionId}`}
|
||||
href={item.href || `#${item.sectionId}`}
|
||||
className="transition-colors duration-150 hover:text-foreground/80 text-foreground/60"
|
||||
>
|
||||
{item.icon}
|
||||
@@ -87,10 +88,11 @@ export default function GlassHeader({ lang }: GlassHeaderProps) {
|
||||
{ key: 'nav.about', icon: '👨💻 ', sectionId: 'about' },
|
||||
{ key: 'nav.services', icon: '🛠️ ', sectionId: 'services' },
|
||||
{ key: 'nav.projects', icon: '🚀 ', sectionId: 'projects' },
|
||||
{ key: 'nav.blog', icon: '📝 ', href: getLocalizedPath('/blog', lang) },
|
||||
].map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
href={`#${item.sectionId}`}
|
||||
href={item.href || `#${item.sectionId}`}
|
||||
className="transition-colors duration-150 hover:text-foreground/80 text-foreground/60 py-2 block"
|
||||
onClick={toggleMenu}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user