feat(i18n): implement comprehensive blog post enhancements
- Add avatar to personal info in data.ts - Remove redundant headings from blog posts - Reorder imports in utils.ts for consistency - Implement new blog layout components including: - PostMeta for displaying post metadata - TableOfContents for navigation - BlogNavigation for post pagination - ShareButtons for social sharing - AuthorCard for author information - Enhance BlogPostLayout with: - Improved typography and spacing - Responsive sidebar layout - Dark mode support - Better code block styling - Remove outdated i18n guide documentation - Add comprehensive styling for all new components
This commit is contained in:
126
src/components/AuthorCard.tsx
Normal file
126
src/components/AuthorCard.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { Lang } from '../i18n/utils';
|
||||
|
||||
interface AuthorCardProps {
|
||||
lang: Lang;
|
||||
author?: {
|
||||
name: string;
|
||||
bio?: string;
|
||||
avatar?: string;
|
||||
website?: string;
|
||||
twitter?: string;
|
||||
github?: string;
|
||||
linkedin?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AuthorCard({ lang, author }: AuthorCardProps) {
|
||||
// Default author info
|
||||
const defaultAuthor = {
|
||||
name: 'Zhao Guiyang',
|
||||
bio: lang === 'zh'
|
||||
? '全栈开发者,专注于现代Web技术和用户体验设计。热爱分享技术见解和最佳实践。'
|
||||
: 'Full-stack developer passionate about modern web technologies and user experience design. Love sharing technical insights and best practices.',
|
||||
avatar: 'https://avatars.githubusercontent.com/u/24975063?v=4', // You can replace with actual avatar
|
||||
website: 'https://zhaoguiyang.com',
|
||||
github: 'https://github.com/zhaoguiyang',
|
||||
twitter: 'https://twitter.com/zhaoguiyang',
|
||||
linkedin: 'https://linkedin.com/in/zhaoguiyang'
|
||||
};
|
||||
|
||||
const authorInfo = author || defaultAuthor;
|
||||
const aboutText = lang === 'zh' ? '关于作者' : 'About the Author';
|
||||
const websiteText = lang === 'zh' ? '个人网站' : 'Website';
|
||||
|
||||
return (
|
||||
<div className="bg-card/50 backdrop-blur-sm rounded-2xl border border-border p-6 relative">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0 mb-4">
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-purple-500 to-blue-500 rounded-full flex items-center justify-center text-white font-bold text-2xl shadow-lg border-4 border-background">
|
||||
{authorInfo.avatar ? (
|
||||
<img
|
||||
src={authorInfo.avatar}
|
||||
alt={authorInfo.name}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
authorInfo.name.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Author Info */}
|
||||
<div className="flex-1">
|
||||
<h4 className="text-lg sm:text-xl font-semibold text-foreground mb-4">
|
||||
{authorInfo.name}
|
||||
</h4>
|
||||
|
||||
{authorInfo.bio && (
|
||||
<p className="text-muted-foreground text-sm sm:text-base mb-6 leading-relaxed max-w-md">
|
||||
{authorInfo.bio}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Social Links */}
|
||||
<div className="flex items-center justify-center space-x-4">
|
||||
{authorInfo.website && (
|
||||
<a
|
||||
href={authorInfo.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full bg-muted text-muted-foreground hover:bg-purple-500/10 hover:text-purple-500 transition-all duration-200"
|
||||
title={websiteText}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9v-9m0-9v9m0 9c-5 0-9-4-9-9s4-9 9-9" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{authorInfo.github && (
|
||||
<a
|
||||
href={authorInfo.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full bg-muted text-muted-foreground hover:bg-gray-500/10 hover:text-gray-700 dark:hover:text-gray-300 transition-all duration-200"
|
||||
title="GitHub"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{authorInfo.twitter && (
|
||||
<a
|
||||
href={authorInfo.twitter}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full bg-muted text-muted-foreground hover:bg-blue-500/10 hover:text-blue-500 transition-all duration-200"
|
||||
title="Twitter"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{authorInfo.linkedin && (
|
||||
<a
|
||||
href={authorInfo.linkedin}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full bg-muted text-muted-foreground hover:bg-blue-600/10 hover:text-blue-600 transition-all duration-200"
|
||||
title="LinkedIn"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
src/components/ShareButtons.tsx
Normal file
122
src/components/ShareButtons.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState } from 'react';
|
||||
import type { Lang } from '../i18n/utils';
|
||||
|
||||
interface ShareButtonsProps {
|
||||
lang: Lang;
|
||||
title: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export default function ShareButtons({ lang, title, url }: ShareButtonsProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const shareUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
|
||||
const shareText = lang === 'zh' ? '分享这篇文章' : 'Share this article';
|
||||
const copyText = lang === 'zh' ? '复制链接' : 'Copy link';
|
||||
const copiedText = lang === 'zh' ? '已复制!' : 'Copied!';
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy link:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const shareLinks = [
|
||||
{
|
||||
name: 'Twitter',
|
||||
url: `https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(shareUrl)}`,
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/>
|
||||
</svg>
|
||||
),
|
||||
color: 'hover:bg-blue-500/10 hover:text-blue-500'
|
||||
},
|
||||
{
|
||||
name: 'LinkedIn',
|
||||
url: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`,
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
),
|
||||
color: 'hover:bg-blue-600/10 hover:text-blue-600'
|
||||
},
|
||||
{
|
||||
name: 'Facebook',
|
||||
url: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`,
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
),
|
||||
color: 'hover:bg-blue-700/10 hover:text-blue-700'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-card/50 backdrop-blur-sm rounded-2xl border border-border p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center">
|
||||
<svg className="w-5 h-5 mr-2 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.367 2.684 3 3 0 00-5.367-2.684z" />
|
||||
</svg>
|
||||
{shareText}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* Social Share Buttons */}
|
||||
{shareLinks.map((link) => (
|
||||
<a
|
||||
key={link.name}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`
|
||||
flex items-center justify-center w-12 h-12 rounded-full
|
||||
bg-muted text-muted-foreground transition-all duration-200
|
||||
${link.color}
|
||||
`}
|
||||
title={`Share on ${link.name}`}
|
||||
>
|
||||
{link.icon}
|
||||
</a>
|
||||
))}
|
||||
|
||||
{/* Copy Link Button */}
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className="
|
||||
flex items-center justify-center w-12 h-12 rounded-full
|
||||
bg-muted text-muted-foreground transition-all duration-200
|
||||
hover:bg-purple-500/10 hover:text-purple-500
|
||||
relative
|
||||
"
|
||||
title={copyText}
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Copy Success Message */}
|
||||
{copied && (
|
||||
<div className="mt-3 text-sm text-purple-500 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="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{copiedText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
src/components/blog/PostMeta.astro
Normal file
142
src/components/blog/PostMeta.astro
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
import { type Lang } from '@/i18n/utils';
|
||||
import { useTranslations } from '@/i18n/utils';
|
||||
|
||||
export interface Props {
|
||||
lang: Lang;
|
||||
publishDate?: string;
|
||||
readingTime?: number;
|
||||
tags?: string[];
|
||||
category?: string | string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
lang,
|
||||
publishDate,
|
||||
readingTime,
|
||||
tags,
|
||||
category,
|
||||
className = ''
|
||||
} = Astro.props;
|
||||
|
||||
const t = useTranslations(lang);
|
||||
|
||||
/**
|
||||
* Format date according to locale
|
||||
*/
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString(lang === 'zh' ? 'zh-CN' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get reading time text based on language
|
||||
*/
|
||||
const getReadingTimeText = (minutes: number) => {
|
||||
if (lang === 'zh') {
|
||||
return `${minutes} 分钟阅读`;
|
||||
}
|
||||
return `${minutes} min read`;
|
||||
};
|
||||
---
|
||||
|
||||
<div class={`space-y-6 ${className}`} data-component="PostMeta">
|
||||
|
||||
<!-- Primary Meta Info: Date and Reading Time -->
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm md:text-base text-muted-foreground">
|
||||
<!-- Publish Date -->
|
||||
{publishDate && (
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 md:w-5 md:h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
<time datetime={publishDate} class="font-medium">
|
||||
{formatDate(publishDate)}
|
||||
</time>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Reading Time -->
|
||||
{readingTime && (
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 md:w-5 md:h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12,6 12,12 16,14"></polyline>
|
||||
</svg>
|
||||
<span class="font-medium">{getReadingTimeText(readingTime)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- Secondary Meta Info: Category and Tags -->
|
||||
{(category || (tags && tags.length > 0)) && (
|
||||
<div class="space-y-3">
|
||||
|
||||
<!-- Category Section -->
|
||||
{category && (
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
分类
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{Array.isArray(category) ? (
|
||||
category.map((cat) => (
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gradient-to-r from-purple-500 to-purple-600 text-white shadow-sm hover:shadow-md transition-all duration-200 hover:scale-105">
|
||||
{cat}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gradient-to-r from-purple-500 to-purple-600 text-white shadow-sm hover:shadow-md transition-all duration-200 hover:scale-105">
|
||||
{category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Tags Section -->
|
||||
{tags && tags.length > 0 && (
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
标签
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{tags.map((tag) => (
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 transition-colors border border-gray-200 dark:border-gray-600">
|
||||
# {tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Ensure smooth transitions for hover effects */
|
||||
.transition-colors {
|
||||
transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.flex-wrap {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
124
src/components/layout/BlogNavigation.astro
Normal file
124
src/components/layout/BlogNavigation.astro
Normal file
@@ -0,0 +1,124 @@
|
||||
---
|
||||
const currentPath = Astro.url.pathname;
|
||||
const isZh = currentPath.includes('/zh/');
|
||||
const lang = isZh ? 'zh' : 'en';
|
||||
const currentSlug = currentPath.split('/').filter(Boolean).pop();
|
||||
|
||||
const enPosts = await Astro.glob('../../pages/blog/posts/*.md');
|
||||
const zhPosts = await Astro.glob('../../pages/zh/blog/posts/*.md');
|
||||
const allPosts = isZh ? zhPosts : enPosts;
|
||||
|
||||
|
||||
interface BlogPost {
|
||||
title: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
frontmatter: {
|
||||
title: string;
|
||||
description?: string;
|
||||
pubDate?: string;
|
||||
date?: string;
|
||||
readTime?: string;
|
||||
tags?: string[];
|
||||
category?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const posts: BlogPost[] = allPosts.map((post) => {
|
||||
const slug = post.url?.split('/').filter(Boolean).pop() || '';
|
||||
return {
|
||||
title: post.frontmatter.title,
|
||||
slug: slug,
|
||||
url: post.url || '',
|
||||
frontmatter: {
|
||||
title: post.frontmatter.title,
|
||||
description: post.frontmatter.description,
|
||||
pubDate: post.frontmatter.pubDate,
|
||||
date: post.frontmatter.date || post.frontmatter.pubDate,
|
||||
readTime: post.frontmatter.readTime,
|
||||
tags: post.frontmatter.tags,
|
||||
category: post.frontmatter.category
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
const sortedPosts = posts
|
||||
.filter(post => post.frontmatter.date || post.frontmatter.pubDate)
|
||||
.sort((a, b) => {
|
||||
const dateA = new Date(a.frontmatter.date || a.frontmatter.pubDate || '').getTime();
|
||||
const dateB = new Date(b.frontmatter.date || b.frontmatter.pubDate || '').getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
|
||||
const currentIndex = sortedPosts.findIndex((post) => post.slug === currentSlug);
|
||||
|
||||
const nextPost = currentIndex > 0 ? sortedPosts[currentIndex - 1] : null;
|
||||
const prevPost = currentIndex < sortedPosts.length - 1 ? sortedPosts[currentIndex + 1] : null;
|
||||
|
||||
const prevText = lang === 'zh' ? '上一篇' : 'Previous Post';
|
||||
const nextText = lang === 'zh' ? '下一篇' : 'Next Post';
|
||||
---
|
||||
|
||||
<nav class="mt-8 flex flex-row gap-2 w-full p-6 max-xl:p-3 max-lg:p-2" role="navigation" aria-label="Blog post navigation">
|
||||
{prevPost && (
|
||||
<a
|
||||
href={prevPost.url}
|
||||
style="width: -webkit-fill-available"
|
||||
class="relative flex min-w-1/2 items-center justify-start gap-2 font-semibold dark:text-white text-black text-left text-pretty text-sm sm:text-base lg:text-lg leading-relaxed hover:text-purple-500 hover:[text-shadow:1px_1px_11px_rgba(168,85,247,0.7)] transition-all duration-300 before:absolute before:-top-6 before:left-0 before:text-xs sm:before:text-sm before:font-light before:content-['Previous_Post'] hover:before:text-purple-400"
|
||||
tabindex="0"
|
||||
aria-label={`${prevText}: ${prevPost.frontmatter.title}`}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.85355 3.85355C7.04882 3.65829 7.04882 3.34171 6.85355 3.14645C6.65829 2.95118 6.34171 2.95118 6.14645 3.14645L2.14645 7.14645C1.95118 7.34171 1.95118 7.65829 2.14645 7.85355L6.14645 11.8536C6.34171 12.0488 6.65829 12.0488 6.85355 11.8536C7.04882 11.6583 7.04882 11.3417 6.85355 11.1464L3.20711 7.5L6.85355 3.85355ZM12.8536 3.85355C13.0488 3.65829 13.0488 3.34171 12.8536 3.14645C12.6583 2.95118 12.3417 2.95118 12.1464 3.14645L8.14645 7.14645C7.95118 7.34171 7.95118 7.65829 8.14645 7.85355L12.1464 11.8536C12.3417 12.0488 12.6583 12.0488 12.8536 11.8536C13.0488 11.6583 13.0488 11.3417 12.8536 11.1464L9.20711 7.5L12.8536 3.85355Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{prevPost.frontmatter.title}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{nextPost && (
|
||||
<a
|
||||
href={nextPost.url}
|
||||
style="width: -webkit-fill-available"
|
||||
class="relative flex min-w-1/2 items-center justify-end gap-2 font-semibold dark:text-white text-black text-right text-pretty text-sm sm:text-base lg:text-lg leading-relaxed hover:text-purple-500 hover:[text-shadow:1px_1px_11px_rgba(168,85,247,0.7)] transition-all duration-300 before:absolute before:-top-6 before:right-0 before:text-xs sm:before:text-sm before:font-light before:content-['Next_Post'] hover:before:text-purple-400"
|
||||
tabindex="0"
|
||||
aria-label={`${nextText}: ${nextPost.frontmatter.title}`}
|
||||
>
|
||||
{nextPost.frontmatter.title}
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.14645 11.1464C1.95118 11.3417 1.95118 11.6583 2.14645 11.8536C2.34171 12.0488 2.65829 12.0488 2.85355 11.8536L6.85355 7.85355C7.04882 7.65829 7.04882 7.34171 6.85355 7.14645L2.85355 3.14645C2.65829 2.95118 2.34171 2.95118 2.14645 3.14645C1.95118 3.34171 1.95118 3.65829 2.14645 3.85355L5.79289 7.5L2.14645 11.1464ZM8.14645 11.1464C7.95118 11.3417 7.95118 11.6583 8.14645 11.8536C8.34171 12.0488 8.65829 12.0488 8.85355 11.8536L12.8536 7.85355C13.0488 7.65829 13.0488 7.34171 12.8536 7.14645L8.85355 3.14645C8.65829 2.95118 8.34171 2.95118 8.14645 3.14645C7.95118 3.34171 7.95118 3.65829 8.14645 3.85355L11.7929 7.5L8.14645 11.1464Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const navLinks = document.querySelectorAll('nav[aria-label="Blog post navigation"] a');
|
||||
|
||||
navLinks.forEach(link => {
|
||||
link.addEventListener('keydown', (event) => {
|
||||
const keyboardEvent = event as KeyboardEvent;
|
||||
if (keyboardEvent.key === 'Enter' || keyboardEvent.key === ' ') {
|
||||
event.preventDefault();
|
||||
const href = (event.target as HTMLAnchorElement).href;
|
||||
if (href) {
|
||||
window.location.href = href;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
nav a:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
nav a:focus {
|
||||
outline: 2px solid rgba(168, 85, 247, 0.5);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
141
src/components/layout/TableOfContents.astro
Normal file
141
src/components/layout/TableOfContents.astro
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
import type { Lang } from '../../i18n/utils';
|
||||
|
||||
interface Props {
|
||||
lang: Lang;
|
||||
}
|
||||
|
||||
const { lang } = Astro.props;
|
||||
const title = lang === 'zh' ? '目录' : 'Table of Contents';
|
||||
---
|
||||
|
||||
<div class="max-xl:hidden">
|
||||
<div id="nav-content" class="sticky w-72 top-14 max-h-[calc(100svh-3.5rem)] overflow-x-hidden">
|
||||
<div class="flex flex-col gap-3 p-4">
|
||||
<h3 class="dark:text-zinc-400 text-blacktext/90 font-bold tracking-wide text-sm sm:text-base uppercase flex items-center mb-4">
|
||||
<svg class="w-4 h-4 sm:w-5 sm:h-5 mr-2 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
{title}
|
||||
</h3>
|
||||
<div class="text-neutral-500 dark:text-neutral-300">
|
||||
<ul id="toc-list" class="leading-relaxed text-sm sm:text-base border-l dark:border-neutral-500/20 border-blacktext/20">
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* Initialize table of contents functionality
|
||||
* Extracts headings from article content and creates navigation links
|
||||
*/
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const tocList = document.getElementById("toc-list");
|
||||
const content = document.querySelector("article");
|
||||
|
||||
if (!tocList || !content) return;
|
||||
|
||||
// Extract h1, h2, h3, h4 headings from article content
|
||||
const headers = content.querySelectorAll("h1, h2, h3, h4");
|
||||
let currentUl = tocList;
|
||||
|
||||
/**
|
||||
* Process each heading and create corresponding TOC entry
|
||||
*/
|
||||
headers.forEach((header, index) => {
|
||||
// Generate ID if not present
|
||||
if (!header.id) {
|
||||
header.id = header.textContent?.trim().toLowerCase().replace(/\s+/g, "-") + "-" + index;
|
||||
}
|
||||
|
||||
const li = document.createElement("li");
|
||||
const link = document.createElement("a");
|
||||
link.href = `#${header.id}`;
|
||||
link.textContent = header.textContent?.trim() || header.id;
|
||||
|
||||
// Apply styling based on heading level
|
||||
const level = parseInt(header.tagName.charAt(1));
|
||||
link.classList.add(
|
||||
"block", "w-full", "text-left", "py-2", "px-3", "rounded-lg", "text-sm",
|
||||
"transition-all", "duration-200", "border-l", "border-transparent",
|
||||
"text-muted-foreground", "hover:text-foreground", "hover:bg-muted/50"
|
||||
);
|
||||
|
||||
// Add indentation based on heading level
|
||||
if (level === 1) {
|
||||
link.classList.add("font-semibold");
|
||||
} else if (level === 2) {
|
||||
link.classList.add("ml-3");
|
||||
} else if (level === 3) {
|
||||
link.classList.add("ml-6");
|
||||
} else if (level === 4) {
|
||||
link.classList.add("ml-9");
|
||||
}
|
||||
|
||||
li.appendChild(link);
|
||||
tocList.appendChild(li);
|
||||
|
||||
/**
|
||||
* Handle smooth scroll on link click
|
||||
*/
|
||||
link.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
const targetElement = document.getElementById(header.id);
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Set up intersection observer for active section tracking
|
||||
*/
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const id = entry.target.getAttribute("id");
|
||||
const link = document.querySelector(`#toc-list a[href="#${id}"]`);
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
// Remove active state from all links
|
||||
document.querySelectorAll("#toc-list a").forEach((el) => {
|
||||
el.classList.remove(
|
||||
"bg-purple-500/10", "text-purple-500", "border-l-2", "border-purple-500"
|
||||
);
|
||||
el.classList.add("text-muted-foreground");
|
||||
});
|
||||
|
||||
// Add active state to current link
|
||||
if (link) {
|
||||
link.classList.remove("text-muted-foreground");
|
||||
link.classList.add(
|
||||
"bg-purple-500/10", "text-purple-500", "border-l-2", "border-purple-500"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
rootMargin: "-20% 0% -35% 0%",
|
||||
threshold: 0
|
||||
}
|
||||
);
|
||||
|
||||
// Observe all headings for intersection
|
||||
headers.forEach((header) => observer.observe(header));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Additional styling for smooth transitions */
|
||||
#toc-list a:hover {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
#toc-list a:focus {
|
||||
outline: 2px solid rgba(168, 85, 247, 0.5);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user