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:
@@ -1,335 +0,0 @@
|
|||||||
# Astro + React 项目国际化 (i18n) 实施步骤
|
|
||||||
|
|
||||||
本文档将严格按照 Astro 官方文档的推荐实践,指导您一步步为您的 Astro + React 项目添加国际化功能。请在完成每个主要步骤后,与指导者确认,然后再进行下一步。
|
|
||||||
|
|
||||||
## 基本配置与约定
|
|
||||||
|
|
||||||
- **默认语言**: `en` (英语)
|
|
||||||
- **支持语言**: `en` (英语), `zh` (简体中文)
|
|
||||||
- **URL 结构**: 不同语言的内容将通过 URL 子路径区分,例如 `example.com/en/` 和 `example.com/zh/`。
|
|
||||||
- **翻译文件格式**: JSON
|
|
||||||
|
|
||||||
## 基本术语
|
|
||||||
|
|
||||||
- **i18n**: Internationalization 的缩写,意为国际化。
|
|
||||||
- **L10n**: Localization 的缩写,意为本地化。
|
|
||||||
- **UI Strings**: 用户界面中需要翻译的文本,例如按钮标签、导航链接文本等。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 步骤 1:配置 Astro i18n 并组织页面
|
|
||||||
|
|
||||||
此步骤的目标是配置 Astro 以支持多种语言,并相应地组织您的页面文件结构。
|
|
||||||
|
|
||||||
### 1.1 配置 `astro.config.mjs`
|
|
||||||
|
|
||||||
您需要在 `astro.config.mjs` 文件中定义国际化相关的配置。虽然 Astro 的核心 i18n 功能不直接在 `defineConfig` 中指定 `locales` 或 `defaultLocale` (这些通常是集成特定 i18n 库或中间件时的配置),但我们将遵循一种通用的模式,即通过目录结构来管理多语言内容。
|
|
||||||
|
|
||||||
目前,我们不需要修改 `astro.config.mjs` 来显式声明语言,Astro 会根据 `src/pages/` 下的目录结构自动处理多语言路由。
|
|
||||||
|
|
||||||
### 1.2 组织多语言页面目录
|
|
||||||
|
|
||||||
1. 在 `src/pages/` 目录下,为项目支持的每种语言创建一个子目录。根据我们的约定,您需要创建以下目录:
|
|
||||||
* `src/pages/en/`
|
|
||||||
* `src/pages/zh/`
|
|
||||||
|
|
||||||
2. 将您现有的页面文件(例如 `index.astro`, `about.astro` 等)复制到每个语言目录下。例如:
|
|
||||||
|
|
||||||
```tree
|
|
||||||
src/
|
|
||||||
└── pages/
|
|
||||||
├── en/
|
|
||||||
│ ├── index.astro
|
|
||||||
│ └── about.astro (如果存在)
|
|
||||||
├── zh/
|
|
||||||
│ ├── index.astro
|
|
||||||
│ └── about.astro (如果存在)
|
|
||||||
└── index.astro (根目录的索引页,将用于重定向)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.3 设置默认语言重定向
|
|
||||||
|
|
||||||
为了让访问网站根路径 (`/`) 的用户自动跳转到默认语言(我们约定为英语 `en`)的首页,请修改位于 `src/pages/index.astro` 的文件,内容如下 <mcreference index="0" link="https://docs.astro.build/zh-cn/recipes/i18n/">0</mcreference>:
|
|
||||||
|
|
||||||
```astro
|
|
||||||
---
|
|
||||||
// src/pages/index.astro
|
|
||||||
// This page will redirect to the default language's home page.
|
|
||||||
---
|
|
||||||
<meta http-equiv="refresh" content="0;url=/en/" />
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键点** <mcreference index="0" link="https://docs.astro.build/zh-cn/recipes/i18n/">0</mcreference>:
|
|
||||||
|
|
||||||
* 这种 `meta refresh` 重定向方法适用于各种部署环境。
|
|
||||||
* 确保您的默认语言首页(例如 `src/pages/en/index.astro`)已创建并包含实际内容。
|
|
||||||
|
|
||||||
**完成后,请与指导者确认,然后再进行下一步。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 步骤 2:创建翻译文件 (UI Strings)
|
|
||||||
|
|
||||||
此步骤的目标是为网站用户界面 (UI) 元素创建翻译字典。这些字典将存储不同语言的文本字符串。
|
|
||||||
|
|
||||||
### 2.1 创建 `ui.ts` 文件
|
|
||||||
|
|
||||||
根据 Astro 官方文档的建议,我们将在 `src/i18n/` 目录下创建一个 `ui.ts` 文件来存储翻译字符串 <mcreference index="0" link="https://docs.astro.build/zh-cn/recipes/i18n/">0</mcreference>。
|
|
||||||
|
|
||||||
1. 在 `src/` 目录下创建一个名为 `i18n` 的新文件夹。
|
|
||||||
2. 在 `src/i18n/` 文件夹中创建一个名为 `ui.ts` 的文件。
|
|
||||||
|
|
||||||
```tree
|
|
||||||
src/
|
|
||||||
├── i18n/
|
|
||||||
│ └── ui.ts
|
|
||||||
└── pages/
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 将以下内容添加到 `src/i18n/ui.ts` 文件中 <mcreference index="0" link="https://docs.astro.build/zh-cn/recipes/i18n/">0</mcreference>:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/i18n/ui.ts
|
|
||||||
export const languages = {
|
|
||||||
en: 'English',
|
|
||||||
zh: '简体中文',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const defaultLang = 'en';
|
|
||||||
|
|
||||||
export const ui = {
|
|
||||||
en: {
|
|
||||||
'nav.home': 'Home',
|
|
||||||
'nav.projects': 'Projects',
|
|
||||||
'nav.experience': 'Experience',
|
|
||||||
'nav.skills': 'Skills',
|
|
||||||
'nav.awards': 'Awards',
|
|
||||||
'nav.education': 'Education',
|
|
||||||
'footer.rights': 'All rights reserved.',
|
|
||||||
// 根据您的项目实际情况添加更多翻译键值对
|
|
||||||
},
|
|
||||||
zh: {
|
|
||||||
'nav.home': '首页',
|
|
||||||
'nav.projects': '项目经历',
|
|
||||||
'nav.experience': '工作经历',
|
|
||||||
'nav.skills': '专业技能',
|
|
||||||
'nav.awards': '奖项荣誉',
|
|
||||||
'nav.education': '教育背景',
|
|
||||||
'footer.rights': '版权所有。',
|
|
||||||
// 根据您的项目实际情况添加更多翻译键值对
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键点** <mcreference index="0" link="https://docs.astro.build/zh-cn/recipes/i18n/">0</mcreference>:
|
|
||||||
|
|
||||||
* `languages`: 一个对象,键是语言代码,值是该语言的人类可读名称。这对于构建语言切换器非常有用。
|
|
||||||
* `defaultLang`: 指定项目的默认语言代码。
|
|
||||||
* `ui`: 一个对象,其键是语言代码。每个语言代码下又是一个对象,包含该语言的翻译键和对应的翻译文本。
|
|
||||||
* 我们使用点号 (`.`) 来组织翻译键的层级,例如 `nav.home`。
|
|
||||||
* `as const` 用于确保 TypeScript 将这些对象视为常量,提供更好的类型推断和自动完成。
|
|
||||||
* **请根据您项目中的实际 UI 文本,在 `ui.en` 和 `ui.zh` 对象中添加或修改相应的翻译键值对。** 例如,导航栏链接、页脚文本、按钮文本等。
|
|
||||||
|
|
||||||
**完成后,请与指导者确认,然后再进行下一步。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 步骤 3:创建翻译辅助函数
|
|
||||||
|
|
||||||
此步骤的目标是创建一些辅助函数,以便在您的 Astro 页面和组件中更轻松地获取和使用翻译字符串。
|
|
||||||
|
|
||||||
### 3.1 创建 `utils.ts` 文件
|
|
||||||
|
|
||||||
我们将在 `src/i18n/` 目录下创建一个 `utils.ts` 文件来存放这些辅助函数 <mcreference index="0" link="https://docs.astro.build/zh-cn/recipes/i18n/">0</mcreference>。
|
|
||||||
|
|
||||||
1. 在 `src/i18n/` 文件夹中创建一个名为 `utils.ts` 的新文件。
|
|
||||||
|
|
||||||
```tree
|
|
||||||
src/
|
|
||||||
├── i18n/
|
|
||||||
│ ├── ui.ts
|
|
||||||
│ └── utils.ts
|
|
||||||
└── pages/
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 将以下内容添加到 `src/i18n/utils.ts` 文件中 <mcreference index="0" link="https://docs.astro.build/zh-cn/recipes/i18n/">0</mcreference>:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/i18n/utils.ts
|
|
||||||
import { ui, defaultLang, languages } from './ui';
|
|
||||||
|
|
||||||
export type Lang = keyof typeof ui;
|
|
||||||
export type UiKeys = keyof typeof ui[typeof defaultLang];
|
|
||||||
|
|
||||||
export function getLangFromUrl(url: URL): Lang {
|
|
||||||
const [, lang] = url.pathname.split('/');
|
|
||||||
if (lang in ui) return lang as Lang;
|
|
||||||
return defaultLang;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTranslations(lang: Lang | undefined) {
|
|
||||||
const currentLang = lang || defaultLang;
|
|
||||||
return function t(key: UiKeys, params?: Record<string, string | number>): string {
|
|
||||||
let translation = ui[currentLang][key] || ui[defaultLang][key];
|
|
||||||
if (params) {
|
|
||||||
Object.keys(params).forEach(paramKey => {
|
|
||||||
const regex = new RegExp(`\\{\\s*${paramKey}\\s*\\}`, 'g');
|
|
||||||
translation = translation.replace(regex, String(params[paramKey]));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return translation;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLocalizedPath(path: string, lang: Lang | undefined, base: string | URL = import.meta.env.BASE_URL): string {
|
|
||||||
const currentLang = lang || defaultLang;
|
|
||||||
const baseWithSlash = typeof base === 'string' && !base.endsWith('/') ? `${base}/` : String(base);
|
|
||||||
const langPath = currentLang === defaultLang ? '' : `${currentLang}/`;
|
|
||||||
const normalizedPath = path.startsWith('/') ? path.substring(1) : path;
|
|
||||||
return `${baseWithSlash}${langPath}${normalizedPath}`.replace(/\/\/+$/, '/'); // Ensure no double slashes at the end
|
|
||||||
}
|
|
||||||
|
|
||||||
export { languages, defaultLang };
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键点** <mcreference index="0" link="https://docs.astro.build/zh-cn/recipes/i18n/">0</mcreference>:
|
|
||||||
|
|
||||||
* `Lang` 和 `UiKeys`: TypeScript 类型定义,用于增强代码的类型安全。
|
|
||||||
* `Lang`: 表示支持的语言代码的类型 (例如 `'en' | 'zh'`)。
|
|
||||||
* `UiKeys`: 表示翻译字典中所有键的类型 (例如 `'nav.home' | 'nav.projects'`)。
|
|
||||||
* `getLangFromUrl(url: URL)`: 一个函数,用于从当前页面的 URL 中提取语言代码。如果 URL 中没有指定语言或者指定的语言无效,则返回默认语言。
|
|
||||||
* `useTranslations(lang: Lang | undefined)`: 一个高阶函数,它接收一个语言代码作为参数,并返回另一个函数 `t`。
|
|
||||||
* 返回的 `t(key: UiKeys, params?: Record<string, string | number>)` 函数用于获取特定键的翻译文本。
|
|
||||||
* 它会首先尝试获取当前语言的翻译,如果找不到,则回退到默认语言的翻译。
|
|
||||||
* 支持可选的 `params` 参数,用于在翻译字符串中插入动态值。例如,如果翻译字符串是 `Hello {name}`,你可以通过 `t('greeting', { name: 'World' })` 来得到 `Hello World`。
|
|
||||||
* `getLocalizedPath(path: string, lang: Lang | undefined, base: string | URL = import.meta.env.BASE_URL)`: 一个函数,用于生成本地化的 URL 路径。它会根据传入的语言和基础路径,自动添加语言子路径(如果不是默认语言)。
|
|
||||||
* `export { languages, defaultLang };`: 重新导出了 `ui.ts` 中的 `languages` 和 `defaultLang`,方便在其他地方统一从 `utils.ts` 导入。
|
|
||||||
|
|
||||||
**完成后,请与指导者确认,然后再进行下一步。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 步骤 4:在 Astro 页面和组件中使用翻译
|
|
||||||
|
|
||||||
此步骤将演示如何在您的 Astro 页面 (`.astro` 文件) 和 React 组件 (`.tsx` 文件) 中使用我们创建的翻译辅助函数来显示本地化文本。
|
|
||||||
|
|
||||||
### 4.1 在 Astro 页面中使用翻译
|
|
||||||
|
|
||||||
在您的 Astro 页面中,您可以导入并使用 `getLangFromUrl` 和 `useTranslations` 函数来获取当前语言并翻译文本。
|
|
||||||
|
|
||||||
**示例:修改 `src/pages/en/index.astro` 和 `src/pages/zh/index.astro`**
|
|
||||||
|
|
||||||
假设您的首页需要显示一个欢迎标题。您可以像这样修改您的语言特定首页文件:
|
|
||||||
|
|
||||||
**`src/pages/en/index.astro`**
|
|
||||||
```astro
|
|
||||||
---
|
|
||||||
import Layout from '../../layouts/Layout.astro';
|
|
||||||
import { getLangFromUrl, useTranslations } from '../../i18n/utils';
|
|
||||||
|
|
||||||
const lang = getLangFromUrl(Astro.url);
|
|
||||||
const t = useTranslations(lang);
|
|
||||||
---
|
|
||||||
<Layout title={t('nav.home')} lang={lang}>
|
|
||||||
<h1>{t('nav.home')}</h1>
|
|
||||||
<p>Welcome to the English version of the site!</p>
|
|
||||||
<p>{t('footer.rights')}</p>
|
|
||||||
</Layout>
|
|
||||||
```
|
|
||||||
|
|
||||||
**`src/pages/zh/index.astro`**
|
|
||||||
```astro
|
|
||||||
---
|
|
||||||
import Layout from '../../layouts/Layout.astro';
|
|
||||||
import { getLangFromUrl, useTranslations } from '../../i18n/utils';
|
|
||||||
|
|
||||||
const lang = getLangFromUrl(Astro.url);
|
|
||||||
const t = useTranslations(lang);
|
|
||||||
---
|
|
||||||
<Layout title={t('nav.home')} lang={lang}>
|
|
||||||
<h1>{t('nav.home')}</h1>
|
|
||||||
<p>欢迎来到本站的中文版本!</p>
|
|
||||||
<p>{t('footer.rights')}</p>
|
|
||||||
</Layout>
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键点** <mcreference index="0" link="https://docs.astro.build/zh-cn/recipes/i18n/">0</mcreference>:
|
|
||||||
|
|
||||||
* 我们从 `../../i18n/utils` 导入了 `getLangFromUrl` 和 `useTranslations`。
|
|
||||||
* `getLangFromUrl(Astro.url)` 用于从当前页面的 URL 获取语言代码。
|
|
||||||
* `useTranslations(lang)` 返回了翻译函数 `t`。
|
|
||||||
* 我们使用 `t('key_name')` 来获取翻译后的字符串,例如 `t('nav.home')` 和 `t('footer.rights')`。
|
|
||||||
* 确保您的 `Layout.astro` 组件能够接收并使用 `lang` prop 来设置 HTML 的 `lang` 属性,例如 `<html lang={Astro.props.lang}>`。
|
|
||||||
|
|
||||||
### 4.2 在 React 组件中使用翻译
|
|
||||||
|
|
||||||
要在 React 组件中使用翻译,您需要将翻译函数 `t` 和当前语言 `lang` 作为 props 传递给组件。
|
|
||||||
|
|
||||||
**示例:创建一个简单的 React 页脚组件 `src/components/Footer.tsx`**
|
|
||||||
|
|
||||||
1. 创建文件 `src/components/Footer.tsx`:
|
|
||||||
|
|
||||||
```tree
|
|
||||||
src/
|
|
||||||
├── components/
|
|
||||||
│ └── Footer.tsx
|
|
||||||
├── i18n/
|
|
||||||
│ ...
|
|
||||||
└── pages/
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 将以下内容添加到 `src/components/Footer.tsx`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// src/components/Footer.tsx
|
|
||||||
import type { Lang, UiKeys } from '../i18n/utils'; // 导入类型
|
|
||||||
|
|
||||||
interface FooterProps {
|
|
||||||
lang: Lang;
|
|
||||||
t: (key: UiKeys, params?: Record<string, string | number>) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Footer({ lang, t }: FooterProps) {
|
|
||||||
return (
|
|
||||||
<footer>
|
|
||||||
<p>{t('footer.rights')}</p>
|
|
||||||
<p>Current language: {lang}</p>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 在您的 Astro 页面中使用此组件,例如在 `src/pages/en/index.astro` 中:
|
|
||||||
|
|
||||||
```astro
|
|
||||||
---
|
|
||||||
import Layout from '../../layouts/Layout.astro';
|
|
||||||
import { getLangFromUrl, useTranslations } from '../../i18n/utils';
|
|
||||||
import Footer from '../../components/Footer.tsx'; // 导入 React 组件
|
|
||||||
|
|
||||||
const lang = getLangFromUrl(Astro.url);
|
|
||||||
const t = useTranslations(lang);
|
|
||||||
---
|
|
||||||
<Layout title={t('nav.home')} lang={lang}>
|
|
||||||
<h1>{t('nav.home')}</h1>
|
|
||||||
<p>Welcome to the English version of the site!</p>
|
|
||||||
|
|
||||||
<Footer lang={lang} t={t} client:visible />
|
|
||||||
</Layout>
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键点**:
|
|
||||||
|
|
||||||
* 我们为 `FooterProps` 定义了接口,明确了 `lang` 和 `t` 的类型。
|
|
||||||
* `lang` 和 `t` 函数从 Astro 页面作为 props 传递给 `Footer` 组件。
|
|
||||||
* 在 React 组件内部,我们直接使用传递进来的 `t` 函数进行翻译。
|
|
||||||
* `client:visible` (或其他 Astro 客户端指令) 是必需的,以使 React 组件在客户端进行交互式渲染。
|
|
||||||
|
|
||||||
**请根据您的项目结构和需求,在相应的 Astro 页面和 React 组件中应用这些翻译方法。**
|
|
||||||
|
|
||||||
**完成后,请与指导者确认,然后再进行下一步。**
|
|
||||||
|
|
||||||
---
|
|
||||||
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>
|
||||||
@@ -31,9 +31,12 @@ const lang = Astro.currentLocale as Lang || defaultLang;
|
|||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-background font-sans antialiased selection:bg-purple-500/20 selection:text-purple-500">
|
<body class="min-h-screen bg-background font-sans antialiased selection:bg-purple-500/20 selection:text-purple-500">
|
||||||
<div
|
<!-- Enhanced background with gradient overlay for better visual consistency -->
|
||||||
class="fixed inset-0 -z-10 h-full w-full bg-background bg-[radial-gradient(ellipse_80%_80%_at_50%_-20%,rgba(120,119,198,0.3),rgba(255,255,255,0))]"
|
<div class="fixed inset-0 -z-10 h-full w-full bg-background">
|
||||||
>
|
<!-- Base radial gradient -->
|
||||||
|
<div class="absolute inset-0 bg-[radial-gradient(ellipse_80%_80%_at_50%_-20%,rgba(120,119,198,0.3),rgba(255,255,255,0))] dark:bg-[radial-gradient(ellipse_80%_80%_at_50%_-20%,rgba(120,119,198,0.2),rgba(0,0,0,0))]"></div>
|
||||||
|
<!-- Additional subtle gradient for blog pages -->
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-br from-purple-50/30 via-transparent to-blue-50/20 dark:from-purple-950/20 dark:via-transparent dark:to-blue-950/10"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Glass Header with navigation -->
|
<!-- Glass Header with navigation -->
|
||||||
<GlassHeader lang={lang} client:load />
|
<GlassHeader lang={lang} client:load />
|
||||||
|
|||||||
@@ -1,24 +1,49 @@
|
|||||||
---
|
---
|
||||||
|
import type { MarkdownLayoutProps } from 'astro';
|
||||||
import { type Lang } from '@/i18n/utils';
|
import { type Lang } from '@/i18n/utils';
|
||||||
import { defaultLang } from '@/i18n/ui';
|
import { defaultLang } from '@/i18n/ui';
|
||||||
import GlassHeader from '@/components/GlassHeader';
|
import GlassHeader from '@/components/GlassHeader';
|
||||||
import Footer from '@/components/Footer';
|
import Footer from '@/components/Footer';
|
||||||
|
import AuthorCard from '@/components/AuthorCard';
|
||||||
|
import TableOfContents from '@/components/layout/TableOfContents.astro';
|
||||||
|
import BlogNavigation from '@/components/layout/BlogNavigation.astro';
|
||||||
|
import PostMeta from '@/components/blog/PostMeta.astro';
|
||||||
|
|
||||||
import "../styles/global.css";
|
import "../styles/global.css";
|
||||||
|
|
||||||
export interface Props {
|
// Define the frontmatter structure
|
||||||
|
interface FrontmatterProps {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
publishDate?: string;
|
publishDate?: string;
|
||||||
|
date?: string; // Alternative field name for publish date
|
||||||
author?: string;
|
author?: string;
|
||||||
|
tags?: string[];
|
||||||
|
category?: string | string[];
|
||||||
|
readingTime?: number;
|
||||||
|
readTime?: string; // Alternative field name for reading time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use Astro's MarkdownLayoutProps for proper type safety
|
||||||
|
export type Props = MarkdownLayoutProps<FrontmatterProps>;
|
||||||
|
|
||||||
|
// Access frontmatter data correctly for markdown layouts
|
||||||
|
const { frontmatter } = Astro.props;
|
||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
description = 'Explore my latest thoughts on coding, tech trends, and developer life.',
|
description,
|
||||||
publishDate,
|
publishDate,
|
||||||
author = 'Zhao Guiyang'
|
date,
|
||||||
} = Astro.props;
|
tags,
|
||||||
|
category,
|
||||||
|
readTime,
|
||||||
|
} = frontmatter;
|
||||||
|
|
||||||
const lang = Astro.currentLocale as Lang || defaultLang;
|
const lang = Astro.currentLocale as Lang || defaultLang;
|
||||||
|
|
||||||
|
// Handle different field names for backward compatibility
|
||||||
|
const finalPublishDate = publishDate || date;
|
||||||
|
const finalReadingTime = readTime ? parseInt(readTime.replace(/\D/g, '')) : undefined;
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -45,33 +70,63 @@ const lang = Astro.currentLocale as Lang || defaultLang;
|
|||||||
<!-- Glass Header with navigation -->
|
<!-- Glass Header with navigation -->
|
||||||
<GlassHeader lang={lang} client:load />
|
<GlassHeader lang={lang} client:load />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Main content with proper spacing for fixed header -->
|
<!-- Main content with proper spacing for fixed header -->
|
||||||
<div class="pt-16">
|
<div class="pt-16 sm:pt-20">
|
||||||
<main class="container mx-auto px-4 py-8 max-w-4xl">
|
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||||
<!-- Blog post header -->
|
<div class="max-w-7xl mx-auto">
|
||||||
<header class="mb-8 text-center">
|
<div class="grid grid-cols-1 xl:grid-cols-4 gap-6 lg:gap-8">
|
||||||
<h1 class="text-4xl font-bold text-foreground mb-4">{title}</h1>
|
<!-- Main Content -->
|
||||||
{publishDate && (
|
<main class="xl:col-span-3 order-2 xl:order-1">
|
||||||
<div class="text-muted-foreground mb-2">
|
<!-- Blog post header -->
|
||||||
<time datetime={publishDate}>{new Date(publishDate).toLocaleDateString(lang === 'zh' ? 'zh-CN' : 'en-US', {
|
<header class="mb-10">
|
||||||
year: 'numeric',
|
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold text-foreground mb-6 leading-tight tracking-tight">
|
||||||
month: 'long',
|
{title}
|
||||||
day: 'numeric'
|
</h1>
|
||||||
})}</time>
|
|
||||||
</div>
|
{description && (
|
||||||
)}
|
<p class="text-lg sm:text-xl text-muted-foreground mb-8 leading-relaxed max-w-4xl">
|
||||||
{author && (
|
{description}
|
||||||
<div class="text-muted-foreground">
|
</p>
|
||||||
By {author}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
<PostMeta
|
||||||
</header>
|
lang={lang}
|
||||||
|
publishDate={finalPublishDate}
|
||||||
<!-- Blog post content with typography styles -->
|
readingTime={finalReadingTime}
|
||||||
<article class="prose prose-lg dark:prose-invert max-w-none prose-headings:text-foreground prose-p:text-muted-foreground prose-strong:text-foreground prose-code:text-foreground prose-pre:bg-muted prose-blockquote:border-l-primary prose-blockquote:text-muted-foreground prose-a:text-primary hover:prose-a:text-primary/80 prose-li:text-muted-foreground prose-img:rounded-lg prose-img:shadow-lg">
|
tags={tags}
|
||||||
<slot />
|
category={category}
|
||||||
</article>
|
className="justify-start"
|
||||||
</main>
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Blog post content with typography styles -->
|
||||||
|
<article class="prose prose-lg dark:prose-invert max-w-none prose-headings:scroll-mt-24 prose-headings:font-bold prose-headings:tracking-tight prose-h1:text-2xl sm:prose-h1:text-3xl prose-h2:text-xl sm:prose-h2:text-2xl prose-h3:text-lg sm:prose-h3:text-xl prose-h4:text-base sm:prose-h4:text-lg prose-p:text-base sm:prose-p:text-lg prose-p:leading-relaxed prose-p:mb-6 prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-strong:text-foreground prose-code:text-sm prose-code:px-2 prose-code:py-1 prose-code:rounded prose-pre:bg-muted prose-pre:border prose-pre:text-sm prose-blockquote:border-l-primary prose-blockquote:bg-muted/30 prose-blockquote:text-base prose-li:text-base sm:prose-li:text-lg prose-li:leading-relaxed">
|
||||||
|
<slot />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- Blog Navigation -->
|
||||||
|
<div class="mt-8 sm:mt-12">
|
||||||
|
<BlogNavigation />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="xl:col-span-1 order-1 xl:order-2">
|
||||||
|
<div class="xl:sticky xl:top-24 space-y-6 sm:space-y-8 xl:space-y-12 xl:max-h-[calc(100vh-6rem)] xl:overflow-y-auto">
|
||||||
|
<!-- Table of Contents -->
|
||||||
|
<div class="bg-card/50 backdrop-blur-sm rounded-2xl border border-border h-auto xl:h-[400px] lg:h-[500px] overflow-y-auto">
|
||||||
|
<TableOfContents lang={lang} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Author Card -->
|
||||||
|
<AuthorCard lang={lang} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
@@ -122,4 +177,147 @@ const lang = Astro.currentLocale as Lang || defaultLang;
|
|||||||
background-color var(--transition-standard),
|
background-color var(--transition-standard),
|
||||||
color var(--transition-standard);
|
color var(--transition-standard);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Enhanced prose styles for better readability */
|
||||||
|
.prose {
|
||||||
|
line-height: 1.75;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.prose {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h1,
|
||||||
|
.prose h2,
|
||||||
|
.prose h3,
|
||||||
|
.prose h4 {
|
||||||
|
scroll-margin-top: 6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the first H1 in markdown content to avoid duplicate titles */
|
||||||
|
.prose h1:first-of-type {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.prose h1 {
|
||||||
|
font-size: 1.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.prose h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h3 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.prose h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h4 {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.prose h4 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose p {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose img {
|
||||||
|
margin: 2rem auto;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose pre {
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose code {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose blockquote {
|
||||||
|
border-left: 4px solid hsl(var(--primary));
|
||||||
|
background: hsl(var(--muted) / 0.3);
|
||||||
|
border-radius: 0 0.5rem 0.5rem 0;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.prose blockquote {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive sidebar */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
aside {
|
||||||
|
order: -1;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky {
|
||||||
|
position: static !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line clamp utilities */
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-clamp-3 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export const personalInfo = {
|
export const personalInfo = {
|
||||||
name: "Joy Zhao",
|
name: "Joy Zhao",
|
||||||
location: "Shanghai, China",
|
location: "Shanghai, China",
|
||||||
|
avatar:"https://avatars.githubusercontent.com/u/24975063?v=4",
|
||||||
email: "zhaoguiyang18@gmail.com",
|
email: "zhaoguiyang18@gmail.com",
|
||||||
github: "https://github.com/zguiyang",
|
github: "https://github.com/zguiyang",
|
||||||
linkedin: "https://linkedin.com/in/zhaoguiyang"
|
linkedin: "https://linkedin.com/in/zhaoguiyang"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { type ClassValue, clsx } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ image: "https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=400&h=250
|
|||||||
date: "May 10, 2025"
|
date: "May 10, 2025"
|
||||||
readTime: "5 min read"
|
readTime: "5 min read"
|
||||||
tags: ["React", "JavaScript", "Frontend"]
|
tags: ["React", "JavaScript", "Frontend"]
|
||||||
|
category: ["React", "Frontend"]
|
||||||
slug: "mastering-react-hooks"
|
slug: "mastering-react-hooks"
|
||||||
layout: "../../../layouts/BlogPostLayout.astro"
|
layout: "../../../layouts/BlogPostLayout.astro"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Mastering React Hooks: A Deep Dive
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
React Hooks have revolutionized the way we write React components, allowing us to use state and other React features in functional components. In this comprehensive guide, we'll explore the most important hooks and learn how to use them effectively.
|
React Hooks have revolutionized the way we write React components, allowing us to use state and other React features in functional components. In this comprehensive guide, we'll explore the most important hooks and learn how to use them effectively.
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ slug: "modern-ui-tailwind"
|
|||||||
layout: "../../../layouts/BlogPostLayout.astro"
|
layout: "../../../layouts/BlogPostLayout.astro"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Building Modern UIs with Tailwind CSS
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Tailwind CSS has revolutionized the way developers approach styling web applications. By providing a comprehensive set of utility classes, it enables rapid development of beautiful, responsive user interfaces without writing custom CSS. Let's explore how to leverage Tailwind CSS to build modern UIs.
|
Tailwind CSS has revolutionized the way developers approach styling web applications. By providing a comprehensive set of utility classes, it enables rapid development of beautiful, responsive user interfaces without writing custom CSS. Let's explore how to leverage Tailwind CSS to build modern UIs.
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ slug: "scaling-nodejs-docker"
|
|||||||
layout: "../../../layouts/BlogPostLayout.astro"
|
layout: "../../../layouts/BlogPostLayout.astro"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Scaling Node.js Apps with Docker
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Docker has revolutionized how we deploy and scale applications. When combined with Node.js, it provides a powerful platform for building scalable, maintainable applications. In this guide, we'll explore how to containerize Node.js applications and scale them effectively.
|
Docker has revolutionized how we deploy and scale applications. When combined with Node.js, it provides a powerful platform for building scalable, maintainable applications. In this guide, we'll explore how to containerize Node.js applications and scale them effectively.
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ slug: "typescript-best-practices"
|
|||||||
layout: "../../../layouts/BlogPostLayout.astro"
|
layout: "../../../layouts/BlogPostLayout.astro"
|
||||||
---
|
---
|
||||||
|
|
||||||
# TypeScript Best Practices for Large Projects
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
TypeScript has become the de facto standard for building large-scale JavaScript applications. Its static type system helps catch errors early, improves code maintainability, and enhances developer productivity. In this comprehensive guide, we'll explore essential TypeScript best practices for enterprise-level projects.
|
TypeScript has become the de facto standard for building large-scale JavaScript applications. Its static type system helps catch errors early, improves code maintainability, and enhances developer productivity. In this comprehensive guide, we'll explore essential TypeScript best practices for enterprise-level projects.
|
||||||
@@ -88,7 +86,7 @@ Start with a robust `tsconfig.json`:
|
|||||||
|
|
||||||
Organize your project for scalability:
|
Organize your project for scalability:
|
||||||
|
|
||||||
```
|
```markdown
|
||||||
src/
|
src/
|
||||||
├── components/ # Reusable UI components
|
├── components/ # Reusable UI components
|
||||||
│ ├── common/ # Shared components
|
│ ├── common/ # Shared components
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ slug: "mastering-react-hooks"
|
|||||||
layout: "../../../../layouts/BlogPostLayout.astro"
|
layout: "../../../../layouts/BlogPostLayout.astro"
|
||||||
---
|
---
|
||||||
|
|
||||||
# 精通 React Hooks:深入探索
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
React Hooks 彻底改变了我们编写 React 组件的方式,让我们能够在函数组件中使用状态和其他 React 特性。在这个全面的指南中,我们将探索最重要的 hooks 并学习如何有效地使用它们。
|
React Hooks 彻底改变了我们编写 React 组件的方式,让我们能够在函数组件中使用状态和其他 React 特性。在这个全面的指南中,我们将探索最重要的 hooks 并学习如何有效地使用它们。
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ slug: "modern-ui-tailwind"
|
|||||||
layout: "../../../../layouts/BlogPostLayout.astro"
|
layout: "../../../../layouts/BlogPostLayout.astro"
|
||||||
---
|
---
|
||||||
|
|
||||||
# 使用 Tailwind CSS 构建现代 UI
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Tailwind CSS 已经彻底改变了我们构建用户界面的方式。作为一个实用优先的 CSS 框架,它提供了低级实用类,让你可以直接在标记中构建完全自定义的设计。在本指南中,我们将探索如何使用 Tailwind CSS 创建现代、响应式的 UI 组件。
|
Tailwind CSS 已经彻底改变了我们构建用户界面的方式。作为一个实用优先的 CSS 框架,它提供了低级实用类,让你可以直接在标记中构建完全自定义的设计。在本指南中,我们将探索如何使用 Tailwind CSS 创建现代、响应式的 UI 组件。
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ slug: "scaling-nodejs-docker"
|
|||||||
layout: "../../../../layouts/BlogPostLayout.astro"
|
layout: "../../../../layouts/BlogPostLayout.astro"
|
||||||
---
|
---
|
||||||
|
|
||||||
# 使用 Docker 扩展 Node.js 应用
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Docker 彻底改变了我们部署和扩展应用程序的方式。当与 Node.js 结合使用时,它提供了一个强大的平台来构建可扩展、可维护的应用程序。在本指南中,我们将探索如何容器化 Node.js 应用程序并有效地扩展它们。
|
Docker 彻底改变了我们部署和扩展应用程序的方式。当与 Node.js 结合使用时,它提供了一个强大的平台来构建可扩展、可维护的应用程序。在本指南中,我们将探索如何容器化 Node.js 应用程序并有效地扩展它们。
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ slug: "typescript-best-practices"
|
|||||||
layout: "../../../../layouts/BlogPostLayout.astro"
|
layout: "../../../../layouts/BlogPostLayout.astro"
|
||||||
---
|
---
|
||||||
|
|
||||||
# TypeScript 在大型项目中的最佳实践
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
TypeScript 已经成为现代 JavaScript 开发的标准选择,特别是在大型项目中。它提供了静态类型检查、更好的 IDE 支持和增强的代码可维护性。在本指南中,我们将探索在企业级应用中使用 TypeScript 的最佳实践。
|
TypeScript 已经成为现代 JavaScript 开发的标准选择,特别是在大型项目中。它提供了静态类型检查、更好的 IDE 支持和增强的代码可维护性。在本指南中,我们将探索在企业级应用中使用 TypeScript 的最佳实践。
|
||||||
@@ -29,7 +27,7 @@ TypeScript 为大型项目带来了显著优势:
|
|||||||
|
|
||||||
### 1. 推荐的项目结构
|
### 1. 推荐的项目结构
|
||||||
|
|
||||||
```
|
```markdown
|
||||||
src/
|
src/
|
||||||
├── components/ # 可重用组件
|
├── components/ # 可重用组件
|
||||||
│ ├── ui/ # 基础 UI 组件
|
│ ├── ui/ # 基础 UI 组件
|
||||||
|
|||||||
Reference in New Issue
Block a user