first commit

This commit is contained in:
2026-01-07 16:24:34 +00:00
commit 5c1399bb96
362 changed files with 59794 additions and 0 deletions

73
src/pages/404.astro Normal file
View File

@@ -0,0 +1,73 @@
---
import { Icon } from "astro-icon/components";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import MainGridLayout from "@/layouts/MainGridLayout.astro";
---
<MainGridLayout title={i18n(I18nKey.notFound)} description={i18n(I18nKey.notFoundDescription)}>
<div class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative min-h-96">
<div class="card-base z-10 px-9 py-12 relative w-full flex flex-col items-center justify-center text-center">
<!-- 404 大号数字 -->
<div class="text-8xl md:text-9xl font-bold text-[var(--primary)] opacity-20 mb-4">
{i18n(I18nKey.notFound)}
</div>
<!-- 404 图标 -->
<div class="mb-6">
<Icon name="material-symbols:error-outline" class="text-6xl text-[var(--primary)]" />
</div>
<!-- 标题 -->
<h1 class="text-3xl md:text-4xl font-bold mb-4 text-90">
{i18n(I18nKey.notFoundTitle)}
</h1>
<!-- 描述 -->
<p class="text-lg text-75 mb-8 max-w-md">
{i18n(I18nKey.notFoundDescription)}
</p>
<!-- 返回首页按钮 -->
<a
href="/"
class="inline-flex items-center gap-2 px-6 py-3 bg-[var(--primary)] text-white rounded-[var(--radius-large)] hover:bg-[var(--btn-content)] transition-colors duration-200 font-medium"
>
<Icon name="material-symbols:home" class="text-xl" />
{i18n(I18nKey.backToHome)}
</a>
<!-- 装饰性元素 -->
<div class="absolute top-4 left-4 opacity-10">
<Icon name="material-symbols:sentiment-sad" class="text-4xl text-[var(--primary)]" />
</div>
<div class="absolute bottom-4 right-4 opacity-10">
<Icon name="material-symbols:search-off" class="text-4xl text-[var(--primary)]" />
</div>
</div>
</div>
</MainGridLayout>
<style>
/* 添加一些动画效果 */
.card-base {
animation: fadeInUp 0.6s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 按钮悬停效果 */
a:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.3);
}
</style>

70
src/pages/[...page].astro Normal file
View File

@@ -0,0 +1,70 @@
---
import type { GetStaticPaths } from "astro";
import Pagination from "@/components/common/controls/Pagination.astro";
import PostPage from "@/components/layout/PostPage.astro";
import { siteConfig } from "@/config";
import MainGridLayout from "@/layouts/MainGridLayout.astro";
import { getSortedPosts } from "@/utils/content-utils";
export const getStaticPaths = (async ({ paginate }) => {
const allBlogPosts = await getSortedPosts();
// 使用配置中的文章数量
const pageSize = siteConfig.pagination.postsPerPage;
return paginate(allBlogPosts, { pageSize });
}) satisfies GetStaticPaths;
// https://github.com/withastro/astro/issues/6507#issuecomment-1489916992
const { page } = Astro.props;
const len = page.data.length;
---
<MainGridLayout>
<PostPage page={page} />
{
page.total > page.size && (
<Pagination
class="mx-auto onload-animation"
page={page}
style={`animation-delay: calc(var(--content-delay) + ${len * 50}ms)`}
/>
)
}
</MainGridLayout>
<script>
// 响应式分页处理脚本
document.addEventListener("DOMContentLoaded", function () {
// 如果启用了响应式分页添加相应的CSS类
const responsiveEnabled = true; // ${siteConfig.pagination.responsive}
if (responsiveEnabled) {
const deviceType = getDeviceType();
document.body.classList.add(`device-${deviceType}`);
// 监听窗口大小变化
let resizeTimeout: any;
window.addEventListener("resize", function () {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(function () {
const newDeviceType = getDeviceType();
const currentDeviceType = document.body.className.match(
/device-(mobile|tablet|desktop)/
)?.[1];
if (currentDeviceType !== newDeviceType) {
document.body.classList.remove(`device-${currentDeviceType}`);
document.body.classList.add(`device-${newDeviceType}`);
}
}, 250);
});
}
});
function getDeviceType() {
if (typeof window === "undefined") return "desktop";
const width = window.innerWidth;
if (width < 768) return "mobile";
if (width < 1024) return "tablet";
return "desktop";
}
</script>

25
src/pages/about.astro Normal file
View File

@@ -0,0 +1,25 @@
---
import { getEntry, render } from "astro:content";
import Markdown from "@components/misc/Markdown.astro";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import MainGridLayout from "@/layouts/MainGridLayout.astro";
const aboutPost = await getEntry("spec", "about");
if (!aboutPost) {
throw new Error("About page content not found");
}
const { Content } = await render(aboutPost);
---
<MainGridLayout title={i18n(I18nKey.about)} description={i18n(I18nKey.about)}>
<div class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full ">
<Markdown class="mt-2">
<Content />
</Markdown>
</div>
</div>
</MainGridLayout>

View File

@@ -0,0 +1,13 @@
import { getSortedPosts } from "@/utils/content-utils";
export async function GET() {
const posts = await getSortedPosts();
const allPostsData = posts.map((post) => ({
id: post.id,
title: post.data.title,
published: post.data.published.getTime(),
}));
return new Response(JSON.stringify(allPostsData));
}

13
src/pages/archive.astro Normal file
View File

@@ -0,0 +1,13 @@
---
import ArchivePanel from "@components/interactive/ArchivePanel.svelte";
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import MainGridLayout from "@layouts/MainGridLayout.astro";
import { getSortedPostsList } from "@/utils/content-utils";
const sortedPostsList = await getSortedPostsList();
---
<MainGridLayout title={i18n(I18nKey.archive)}>
<ArchivePanel sortedPosts={sortedPostsList} client:only="svelte" />
</MainGridLayout>

251
src/pages/bangumi.astro Normal file
View File

@@ -0,0 +1,251 @@
---
import Icon from "@/components/misc/Icon.astro";
import BangumiSection from "@/components/pages/bangumi/BangumiSection.astro";
import TabNav from "@/components/pages/bangumi/TabNav.astro";
import { siteConfig } from "@/config";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import MainGridLayout from "@/layouts/MainGridLayout.astro";
import type {
UserSubjectCollection,
UserSubjectCollectionResponse,
} from "@/types/bangumi";
import { formatDateI18nWithTime } from "@/utils/date-utils";
//参考:霞葉 https://kasuha.com/posts/fuwari-enhance-ep2/
// 检查页面是否启用
if (!siteConfig.pages.bangumi) {
return Astro.redirect("/404/");
}
// 获取构建时间
const buildTime = formatDateI18nWithTime(new Date());
////////////// Bangumi 配置 ////////////////////////
const bangumiConfig = {
username: siteConfig.bangumi?.userId, // 在这里配置你的Bangumi用户名
apiUrl: "https://api.bgm.tv",
categories: {
book: true,
anime: true,
music: true,
game: true,
real: false,
},
// 数据获取设置
pagination: {
limit: 50, // 每页获取数量
delay: 50, // 请求间隔毫秒数,避免频率限制
maxTotal: 1000, // 最大获取总数防止无限循环0=无限制)
},
};
//////////////////////////////////////////////////
// 分类映射
const categoryMap = {
book: { id: "book", name: i18n(I18nKey.bangumiCategoryBook), subjectType: 1 },
anime: {
id: "anime",
name: i18n(I18nKey.bangumiCategoryAnime),
subjectType: 2,
},
music: {
id: "music",
name: i18n(I18nKey.bangumiCategoryMusic),
subjectType: 3,
},
game: { id: "game", name: i18n(I18nKey.bangumiCategoryGame), subjectType: 4 },
real: { id: "real", name: i18n(I18nKey.bangumiCategoryReal), subjectType: 6 },
};
// 获取Bangumi数据的函数 - 支持分页获取所有数据
async function fetchBangumiData(username: string, subjectType: number) {
try {
const { limit, delay, maxTotal } = bangumiConfig.pagination;
let offset = 0;
let allData: UserSubjectCollection[] = [];
let hasMore = true;
// 开发模式下只获取一页数据,加快调试速度
const isDev = import.meta.env.DEV;
const maxPages = isDev ? 1 : 0; // 0 表示不限制
console.log(
`[Bangumi] ${isDev ? "🔧 开发模式" : "🌐 生产模式"} - 开始获取用户 ${username} 的 subjectType ${subjectType} 数据...`,
);
while (hasMore) {
// 开发模式限制:只获取一页
if (isDev && maxPages > 0 && allData.length >= limit * maxPages) {
console.log(`[Bangumi] 开发模式:已获取 ${maxPages} 页数据,停止获取`);
break;
}
// 检查是否超过最大获取限制
if (maxTotal > 0 && allData.length >= maxTotal) {
console.log(`[Bangumi] 已达到最大获取限制 ${maxTotal},停止获取`);
break;
}
const url = `${bangumiConfig.apiUrl}/v0/users/${username}/collections?subject_type=${subjectType}&limit=${limit}&offset=${offset}`;
console.log(`[Bangumi] 正在获取数据: ${url} (已获取: ${allData.length})`);
const response = await fetch(url, {
headers: {
"User-Agent": "YuuOuRou Blog",
Accept: "application/json",
},
});
if (!response.ok) {
console.warn(
`[Bangumi] 无法获取数据 (状态码: ${response.status}):`,
url,
);
break;
}
const data = (await response.json()) as UserSubjectCollectionResponse;
const currentBatch = data.data || [];
if (currentBatch.length > 0) {
allData = allData.concat(currentBatch);
offset += limit;
// 如果本次获取的数据少于limit说明已经是最后一页
if (currentBatch.length < limit) {
hasMore = false;
}
} else {
hasMore = false;
}
// 添加延迟避免请求过于频繁
if (hasMore) {
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
console.log(`[Bangumi] 总共获取到 ${allData.length} 条数据`);
return allData;
} catch (error) {
console.error("[Bangumi] 获取数据时出错:", error);
return [];
}
}
// 获取所有启用分类的数据
const bangumiData: Record<string, UserSubjectCollection[]> = {};
const tabs: Array<{ id: string; name: string; count: number }> = [];
// 检查是否已配置 Bangumi 用户ID
const isUserIdConfigured =
bangumiConfig.username &&
bangumiConfig.username !== "you-user-id" &&
bangumiConfig.username.trim() !== "";
if (!isUserIdConfigured) {
console.log("[Bangumi] ⚠️ 未配置 Bangumi 用户ID跳过数据获取");
} else {
console.log("[Bangumi] 🌐 从 API 获取数据");
}
for (const [categoryKey, enabled] of Object.entries(bangumiConfig.categories)) {
// 如果未配置用户ID跳过数据获取
if (!isUserIdConfigured) {
break;
}
if (enabled && categoryMap[categoryKey as keyof typeof categoryMap]) {
const categoryInfo = categoryMap[categoryKey as keyof typeof categoryMap];
try {
const data = await fetchBangumiData(
// biome-ignore lint/style/noNonNullAssertion: Checked by isUserIdConfigured
bangumiConfig.username!,
categoryInfo.subjectType,
);
bangumiData[categoryKey] = data;
tabs.push({
id: categoryKey,
name: categoryInfo.name,
count: data.length,
});
} catch (error) {
console.error(`[Bangumi] 获取 ${categoryInfo.name} 数据失败:`, error);
bangumiData[categoryKey] = [];
tabs.push({
id: categoryKey,
name: categoryInfo.name,
count: 0,
});
}
}
}
const activeTab = tabs[0]?.id || "anime";
---
<MainGridLayout title={i18n(I18nKey.bangumi)} description={i18n(I18nKey.bangumiSubtitle)}>
<div class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full">
<!-- 页面标题 -->
<div class="relative w-full mb-8">
<div class="mb-6">
<div class="flex items-center gap-3 mb-3">
<div class="h-8 w-8 rounded-lg bg-[var(--primary)] flex items-center justify-center text-white dark:text-black/70">
<Icon icon="material-symbols:movie" class="text-[1.5rem]"></Icon>
</div>
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100">
{i18n(I18nKey.bangumi)}
</h1>
</div>
<p class="text-base text-neutral-600 dark:text-neutral-400 leading-relaxed">
{i18n(I18nKey.bangumiSubtitle)}
</p>
<p class="text-xs text-neutral-500 dark:text-neutral-500 mt-2">
{i18n(I18nKey.bangumiLastUpdated)} {buildTime}
</p>
</div>
<!-- Tab 导航和内容 -->
{tabs.length > 0 ? (
<>
<TabNav tabs={tabs} activeTab={activeTab} />
<!-- 内容区域 -->
{tabs.map((tab) => (
<BangumiSection
sectionId={tab.id}
items={bangumiData[tab.id] || []}
isActive={tab.id === activeTab}
itemsPerPage={6}
/>
))}
</>
) : (
<div class="text-center py-16">
<div class="inline-flex items-center justify-center w-16 h-16 bg-[var(--btn-regular-bg)] rounded-full mb-6 border border-[var(--line-divider)]">
<Icon icon="material-symbols:settings" class="text-[2rem] text-[var(--btn-content)]" />
</div>
<h2 class="text-xl font-semibold text-black/80 dark:text-white/80 mb-3">
{isUserIdConfigured ? i18n(I18nKey.bangumiEmpty) : "未配置 Bangumi 用户ID"}
</h2>
<p class="text-black/60 dark:text-white/60 mb-4 max-w-md mx-auto">
{isUserIdConfigured ? i18n(I18nKey.bangumiEmptyReason) : "请在 src/config/siteConfig.ts 中配置你的 Bangumi 用户ID"}
</p>
</div>
)}
</div>
</div>
</MainGridLayout>
<style>
/* 自定义样式 */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

212
src/pages/friends.astro Normal file
View File

@@ -0,0 +1,212 @@
---
import { getEntry, render } from "astro:content";
import Comment from "@components/comment/index.astro";
import Markdown from "@components/misc/Markdown.astro";
import Icon from "@/components/misc/Icon.astro";
import { friendsPageConfig, getEnabledFriends } from "@/config";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import MainGridLayout from "@/layouts/MainGridLayout.astro";
const friendsPost = await getEntry("spec", "friends");
if (!friendsPost) {
throw new Error("friends page content not found");
}
const { Content } = await render(friendsPost);
// 使用配置文件中的友链数据,按权重排序
const items = getEnabledFriends();
const allTags = [...new Set(items.flatMap((item) => item.tags || []))].sort();
// 页面标题和描述
const title = i18n(I18nKey.friends);
const description = i18n(I18nKey.friendsDescription);
---
<MainGridLayout title={title} description={description}>
<div
class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative min-h-32"
>
<div class="card-base z-10 px-9 py-6 relative w-full">
<!-- 页面标题和描述 -->
<div class="mb-4">
<div class="flex items-center gap-3 mb-3">
<div
class="h-8 w-8 rounded-lg bg-[var(--primary)] flex items-center justify-center text-white dark:text-black/70"
>
<Icon icon="material-symbols:group" class="text-[1.5rem]" />
</div>
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100">
{title}
</h1>
</div>
{
description && (
<p class="text-base text-neutral-600 dark:text-neutral-400 leading-relaxed mb-4">
{description}
</p>
)
}
</div>
<!-- 标签筛选 -->
{
items.length > 0 && (
<friend-filter class="flex flex-wrap gap-2 mb-6">
<button
data-tag="all"
class="btn-regular px-3 py-1.5 rounded-lg bg-[var(--primary)] text-white hover:bg-[var(--primary)] transition-colors duration-200 text-sm font-medium"
>
{i18n(I18nKey.all)}
</button>
{allTags.map((tag) => (
<button
data-tag={tag}
class="btn-regular px-3 py-1.5 rounded-lg bg-[var(--btn-regular-bg)] text-[var(--btn-content)] hover:bg-[var(--btn-regular-bg-hover)] transition-colors duration-200 text-sm font-medium"
>
{tag}
</button>
))}
</friend-filter>
)
}
<div class={`grid grid-cols-1 sm:grid-cols-2 ${friendsPageConfig.columns === 2 ? 'lg:grid-cols-2' : 'lg:grid-cols-3'} gap-3 my-4`}>
{
items.map((item) => (
<a
href={item.siteurl}
target="_blank"
rel="noopener noreferrer"
data-tags={item.tags?.join(",")}
class="friend-card group flex items-center gap-3 p-2.5 rounded-xl border border-[var(--line-divider)] hover:border-[var(--primary)] hover:bg-[var(--card-bg)] transition-all duration-300 hover:shadow-lg relative overflow-hidden"
>
{/* 背景装饰 - 仅悬停显示 */}
<div class="absolute inset-0 bg-[var(--primary)] opacity-0 group-hover:opacity-5 transition-opacity duration-300 pointer-events-none" />
{/* 头像 */}
<div class="relative w-16 h-16 flex-shrink-0 rounded-xl overflow-hidden bg-zinc-100 dark:bg-zinc-800 border border-black/5 dark:border-white/5 group-hover:scale-105 transition-transform duration-300">
<img
src={item.imgurl}
alt={item.title}
class="w-full h-full object-cover"
/>
</div>
{/* 内容 */}
<div class="grow min-w-0 flex flex-col justify-center gap-0.5">
<div class="flex items-center justify-between">
<div class="font-bold text-base text-neutral-900 dark:text-neutral-100 group-hover:text-[var(--primary)] transition-colors truncate pr-4">
{item.title}
</div>
{/* 外部链接图标 */}
<Icon
icon="material-symbols:arrow-outward-rounded"
class="text-[var(--primary)] text-lg opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300"
/>
</div>
<div
class="text-sm text-neutral-500 dark:text-neutral-400 line-clamp-1"
title={item.desc}
>
{item.desc}
</div>
{/* 标签 */}
<div class="flex flex-wrap gap-1 mt-1">
{item.tags && item.tags.length > 0
? item.tags
.slice(0, 3)
.map((tag) => (
<span class="text-[0.65rem] px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400 transition-colors duration-300">
{tag}
</span>
))
: null}
</div>
</div>
</a>
))
}
</div>
<Markdown class="mt-2">
<Content />
</Markdown>
</div>
</div>
<!-- 评论区 -->
<div class="mt-4">
<Comment post={friendsPost} customPath="/friends/" />
</div>
<script slot="head" is:inline>
if (!customElements.get("friend-filter")) {
class FriendFilter extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.addEventListener("click", this.handleClick);
}
disconnectedCallback() {
this.removeEventListener("click", this.handleClick);
}
handleClick(e) {
const target = e.target;
const button = target.closest("button");
if (!button) return;
const selectedTag = button.dataset.tag;
const filters = this.querySelectorAll("button");
const container = this.closest(".card-base");
if (!container) return;
const cards = container.querySelectorAll(".friend-card");
filters.forEach((f) => {
f.classList.remove("bg-[var(--primary)]", "text-white");
f.classList.remove("hover:bg-[var(--primary)]");
f.classList.add(
"bg-[var(--btn-regular-bg)]",
"text-[var(--btn-content)]"
);
f.classList.add("hover:bg-[var(--btn-regular-bg-hover)]");
if (f === button) {
f.classList.remove(
"bg-[var(--btn-regular-bg)]",
"text-[var(--btn-content)]"
);
f.classList.remove("hover:bg-[var(--btn-regular-bg-hover)]");
f.classList.add("bg-[var(--primary)]", "text-white");
f.classList.add("hover:bg-[var(--primary)]");
}
});
cards.forEach((card) => {
const cardEl = card;
const tags = (cardEl.dataset.tags || "").split(",");
if (
selectedTag === "all" ||
(selectedTag && tags.includes(selectedTag))
) {
cardEl.style.display = "";
cardEl.classList.add("animate-fade-in-up");
} else {
cardEl.style.display = "none";
cardEl.classList.remove("animate-fade-in-up");
}
});
}
}
customElements.define("friend-filter", FriendFilter);
}
</script>
</MainGridLayout>

90
src/pages/guestbook.astro Normal file
View File

@@ -0,0 +1,90 @@
---
import { getEntry, render } from "astro:content";
import Comment from "@components/comment/index.astro";
import Markdown from "@components/misc/Markdown.astro";
import Icon from "@/components/misc/Icon.astro";
import { commentConfig, siteConfig } from "@/config";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import MainGridLayout from "@/layouts/MainGridLayout.astro";
// 检查页面是否启用
if (!siteConfig.pages.guestbook) {
return Astro.redirect("/404/");
}
const guestbookPost = await getEntry("spec", "guestbook");
if (!guestbookPost) {
throw new Error("guestbook page content not found");
}
const { Content } = await render(guestbookPost);
// 检查评论系统是否启用
const isCommentEnabled = commentConfig?.type && commentConfig.type !== "none";
// 页面标题和描述
const title = i18n(I18nKey.guestbook);
const description = i18n(I18nKey.guestbookDescription);
---
<MainGridLayout title={title} description={description}>
<div
class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative min-h-32"
>
<div class="card-base z-10 px-9 py-6 relative w-full">
<!-- 页面标题和描述 -->
<div class="mb-8">
<div class="flex items-center gap-3 mb-3">
<div class="h-8 w-8 rounded-lg bg-[var(--primary)] flex items-center justify-center text-white dark:text-black/70">
<Icon icon="material-symbols:chat" class="text-[1.5rem]"></Icon>
</div>
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100">
{title}
</h1>
</div>
{description && (
<p class="text-base text-neutral-600 dark:text-neutral-400 leading-relaxed mb-4">
{description}
</p>
)}
</div>
<Markdown class="mt-2">
<Content />
</Markdown>
</div>
</div>
<!-- 评论区 -->
<div class="mt-4">
{isCommentEnabled ? (
<Comment post={guestbookPost} customPath="/guestbook/" />
) : (
<div class="card-base p-8 mb-6 relative overflow-hidden">
<div class="text-center py-8 text-[var(--content-meta)]">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-[var(--btn-regular-bg)] flex items-center justify-center">
<svg
class="w-8 h-8 text-[var(--primary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
></path>
</svg>
</div>
<p class="text-base mb-2">{i18n(I18nKey.commentNotConfigured)}</p>
<p class="text-sm text-[var(--content-meta)] opacity-75">
{i18n(I18nKey.guestbookCommentHint)}
</p>
</div>
</div>
)}
</div>
</MainGridLayout>

View File

@@ -0,0 +1,354 @@
import type { CollectionEntry } from "astro:content";
import { getCollection } from "astro:content";
import * as fs from "node:fs";
import type { APIContext, GetStaticPaths } from "astro";
import satori from "satori";
import sharp from "sharp";
import { removeFileExtension } from "@/utils/url-utils";
import { profileConfig } from "../../config/profileConfig";
import { siteConfig } from "../../config/siteConfig";
type Weight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
type FontStyle = "normal" | "italic";
interface FontOptions {
data: Buffer | ArrayBuffer;
name: string;
weight?: Weight;
style?: FontStyle;
lang?: string;
}
export const prerender = true;
export const getStaticPaths: GetStaticPaths = async () => {
if (!siteConfig.generateOgImages) {
return [];
}
const allPosts = await getCollection("posts");
const publishedPosts = allPosts.filter((post) => !post.data.draft);
return publishedPosts.map((post) => {
// 将 id 转换为 slug移除扩展名以匹配路由参数
const slug = removeFileExtension(post.id);
return {
params: { slug },
props: { post },
};
});
};
let fontCache: { regular: Buffer | null; bold: Buffer | null } | null = null;
async function fetchNotoSansSCFonts() {
if (fontCache) return fontCache;
try {
const cssResp = await fetch(
"https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&display=swap",
);
if (!cssResp.ok) throw new Error("Failed to fetch Google Fonts CSS");
const cssText = await cssResp.text();
const getUrlForWeight = (weight: number) => {
const blockRe = new RegExp(
`@font-face\\s*{[^}]*font-weight:\\s*${weight}[^}]*}`,
"g",
);
const match = cssText.match(blockRe);
if (!match || match.length === 0) return null;
const urlMatch = match[0].match(/url\((https:[^)]+)\)/);
return urlMatch ? urlMatch[1] : null;
};
const regularUrl = getUrlForWeight(400);
const boldUrl = getUrlForWeight(700);
if (!regularUrl || !boldUrl) {
console.warn(
"Could not find font urls in Google Fonts CSS; falling back to no fonts.",
);
fontCache = { regular: null, bold: null };
return { regular: null, bold: null };
}
const [rResp, bResp] = await Promise.all([
fetch(regularUrl),
fetch(boldUrl),
]);
if (!rResp.ok || !bResp.ok) {
console.warn(
"Failed to download font files from Google; falling back to no fonts.",
);
fontCache = { regular: null, bold: null };
return { regular: null, bold: null };
}
const rBuf = Buffer.from(await rResp.arrayBuffer());
const bBuf = Buffer.from(await bResp.arrayBuffer());
fontCache = { regular: rBuf, bold: bBuf };
return fontCache;
} catch (err) {
console.warn("Error fetching fonts:", err);
fontCache = { regular: null, bold: null };
return { regular: null, bold: null };
}
}
export async function GET({
props,
}: APIContext<{ post: CollectionEntry<"posts"> }>) {
const { post } = props;
// Try to fetch fonts from Google Fonts (woff2) at runtime.
const { regular: fontRegular, bold: fontBold } = await fetchNotoSansSCFonts();
// Avatar + icon: still read from disk (small assets)
let avatarBase64: string;
// 检查头像是否为 URL
if (profileConfig.avatar?.startsWith("http")) {
// 如果是 URL直接使用
avatarBase64 = profileConfig.avatar;
} else {
// 如果是本地路径,从 public 目录读取
const avatarPath = profileConfig.avatar?.startsWith("/")
? `./public${profileConfig.avatar}`
: `./src/${profileConfig.avatar}`;
const avatarBuffer = fs.readFileSync(avatarPath);
avatarBase64 = `data:image/png;base64,${avatarBuffer.toString("base64")}`;
}
let iconPath = "./public/favicon/favicon-dark-192.png";
if (siteConfig.favicon.length > 0) {
iconPath = `./public${siteConfig.favicon[0].src}`;
}
const iconBuffer = fs.readFileSync(iconPath);
const iconBase64 = `data:image/png;base64,${iconBuffer.toString("base64")}`;
const hue = siteConfig.themeColor.hue;
const primaryColor = `hsl(${hue}, 90%, 65%)`;
const textColor = "hsl(0, 0%, 95%)";
const subtleTextColor = `hsl(${hue}, 10%, 75%)`;
const backgroundColor = `hsl(${hue}, 15%, 12%)`;
const pubDate = post.data.published.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
const description = post.data.description;
const template = {
type: "div",
props: {
style: {
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: backgroundColor,
fontFamily:
'"Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
padding: "60px",
},
children: [
{
type: "div",
props: {
style: {
width: "100%",
display: "flex",
alignItems: "center",
gap: "20px",
},
children: [
{
type: "img",
props: {
src: iconBase64,
width: 48,
height: 48,
style: { borderRadius: "10px" },
},
},
{
type: "div",
props: {
style: {
fontSize: "36px",
fontWeight: 600,
color: subtleTextColor,
},
children: siteConfig.title,
},
},
],
},
},
{
type: "div",
props: {
style: {
display: "flex",
flexDirection: "column",
justifyContent: "center",
flexGrow: 1,
gap: "20px",
},
children: [
{
type: "div",
props: {
style: {
display: "flex",
alignItems: "flex-start",
},
children: [
{
type: "div",
props: {
style: {
width: "10px",
height: "68px",
backgroundColor: primaryColor,
borderRadius: "6px",
marginTop: "14px",
},
},
},
{
type: "div",
props: {
style: {
fontSize: "72px",
fontWeight: 700,
lineHeight: 1.2,
color: textColor,
marginLeft: "25px",
display: "-webkit-box",
overflow: "hidden",
textOverflow: "ellipsis",
lineClamp: 3,
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
},
children: post.data.title,
},
},
],
},
},
description && {
type: "div",
props: {
style: {
fontSize: "32px",
lineHeight: 1.5,
color: subtleTextColor,
paddingLeft: "35px",
display: "-webkit-box",
overflow: "hidden",
textOverflow: "ellipsis",
lineClamp: 2,
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
},
children: description,
},
},
],
},
},
{
type: "div",
props: {
style: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
width: "100%",
},
children: [
{
type: "div",
props: {
style: {
display: "flex",
alignItems: "center",
gap: "20px",
},
children: [
{
type: "img",
props: {
src: avatarBase64,
width: 60,
height: 60,
style: { borderRadius: "50%" },
},
},
{
type: "div",
props: {
style: {
fontSize: "28px",
fontWeight: 600,
color: textColor,
},
children: profileConfig.name,
},
},
],
},
},
{
type: "div",
props: {
style: { fontSize: "28px", color: subtleTextColor },
children: pubDate,
},
},
],
},
},
],
},
};
const fonts: FontOptions[] = [];
if (fontRegular) {
fonts.push({
name: "Noto Sans SC",
data: fontRegular,
weight: 400,
style: "normal",
});
}
if (fontBold) {
fonts.push({
name: "Noto Sans SC",
data: fontBold,
weight: 700,
style: "normal",
});
}
const svg = await satori(template, {
width: 1200,
height: 630,
fonts,
});
const png = await sharp(Buffer.from(svg)).png().toBuffer();
return new Response(new Uint8Array(png), {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}

View File

@@ -0,0 +1,393 @@
---
import { render } from "astro:content";
import * as path from "node:path";
import Comment from "@components/comment/index.astro";
import License from "@components/misc/License.astro";
import Markdown from "@components/misc/Markdown.astro";
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import MainGridLayout from "@layouts/MainGridLayout.astro";
import { getSortedPosts } from "@utils/content-utils";
import {
getFileDirFromPath,
getPostUrlBySlug,
removeFileExtension,
} from "@utils/url-utils";
import { Icon } from "astro-icon/components";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import PostMetadata from "@/components/content/PostMeta.astro";
import RandomCoverImage from "@/components/misc/RandomCoverImage.astro";
import SharePoster from "@/components/misc/SharePoster.svelte";
import { coverImageConfig } from "@/config/coverImageConfig";
import { licenseConfig } from "@/config/licenseConfig";
import { profileConfig } from "@/config/profileConfig";
import { siteConfig } from "@/config/siteConfig";
import { sponsorConfig } from "@/config/sponsorConfig";
import { formatDateToYYYYMMDD } from "@/utils/date-utils";
import { processCoverImageSync } from "@/utils/image-utils";
import { url } from "@/utils/url-utils";
export async function getStaticPaths() {
const blogEntries = await getSortedPosts();
return blogEntries.map((entry) => {
// 将 id 转换为 slug移除扩展名以匹配路由参数
const slug = removeFileExtension(entry.id);
return {
params: { slug },
props: { entry },
};
});
}
const { entry } = Astro.props;
const { Content, headings } = await render(entry);
const { remarkPluginFrontmatter } = await render(entry);
// 处理随机图如果image为"api"则从配置的API获取随机图
const processedImage = processCoverImageSync(entry.data.image, entry.id);
let posterCoverUrl = processedImage;
if (processedImage) {
const isLocal = !(
processedImage.startsWith("/") ||
processedImage.startsWith("http") ||
processedImage.startsWith("https") ||
processedImage.startsWith("data:")
);
if (isLocal) {
const basePath = getFileDirFromPath(entry.filePath || "");
const files = import.meta.glob<ImageMetadata>("../../**", {
import: "default",
});
let normalizedPath = path
.normalize(path.join("../../", basePath, processedImage))
.replace(/\\/g, "/");
const file = files[normalizedPath];
if (file) {
const img = await file();
posterCoverUrl = img.src;
}
}
}
dayjs.extend(utc);
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: entry.data.title,
description: entry.data.description || entry.data.title,
keywords: entry.data.tags,
author: {
"@type": "Person",
name: profileConfig.name,
url: Astro.site,
},
datePublished: formatDateToYYYYMMDD(entry.data.published),
inLanguage: entry.data.lang
? entry.data.lang.replace("_", "-")
: siteConfig.lang.replace("_", "-"),
// TODO include cover image here
};
---
<MainGridLayout
banner={processedImage}
title={entry.data.title}
description={entry.data.description}
lang={entry.data.lang}
setOGTypeArticle={true}
postSlug={entry.id}
headings={headings}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={JSON.stringify(jsonLd)}
/>
<div
class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative mb-4"
>
<div
id="post-container"
class:list={[
"card-base z-10 px-6 md:px-9 pt-6 pb-4 relative w-full ",
{},
]}
>
<!-- word count and reading time -->
<div
class="flex flex-row text-black/30 dark:text-white/30 gap-5 mb-3 transition onload-animation"
>
<div class="flex flex-row items-center">
<div
class="transition h-6 w-6 rounded-md bg-black/5 dark:bg-white/10 text-black/50 dark:text-white/50 flex items-center justify-center mr-2"
>
<Icon name="material-symbols:notes-rounded" />
</div>
<div class="text-sm">
{remarkPluginFrontmatter.words}
{" " + i18n(I18nKey.wordsCount)}
</div>
</div>
<div class="flex flex-row items-center">
<div
class="transition h-6 w-6 rounded-md bg-black/5 dark:bg-white/10 text-black/50 dark:text-white/50 flex items-center justify-center mr-2"
>
<Icon name="material-symbols:schedule-outline-rounded" />
</div>
<div class="text-sm">
{remarkPluginFrontmatter.minutes}
{
" " +
i18n(
remarkPluginFrontmatter.minutes === 1
? I18nKey.minuteCount
: I18nKey.minutesCount
)
}
</div>
</div>
</div>
<!-- title -->
<div class="relative onload-animation">
<h1
data-pagefind-body
data-pagefind-weight="10"
data-pagefind-meta="title"
class="transition w-full block font-bold mb-3
text-3xl md:text-[2.25rem]/[2.75rem]
text-black/90 dark:text-white/90
md:before:w-1 before:h-5 before:rounded-md before:bg-[var(--primary)]
before:absolute before:top-[0.75rem] before:left-[-1.125rem]"
>
{entry.data.title}
</h1>
</div>
<!-- metadata -->
<div class="onload-animation">
<PostMetadata
className="mb-5"
published={entry.data.published}
updated={entry.data.updated}
tags={entry.data.tags}
category={entry.data.category || undefined}
id={entry.id}
/>
{
!processedImage && (
<div class="border-[var(--line-divider)] border-dashed border-b-[1px] mt-3 mb-5" />
)
}
</div>
<!-- always show cover as long as it has one -->
{
processedImage && coverImageConfig.enableInPost && (
<div style="margin-top:1rem;">
<RandomCoverImage
id="post-cover"
src={processedImage}
basePath={getFileDirFromPath(entry.filePath || '')}
class="mb-8 rounded-xl banner-container onload-animation"
seed={entry.id}
preview={false}
/>
</div>
)
}
<Markdown class="mb-6 markdown-content onload-animation">
<Content />
</Markdown>
{/* 赞助按钮 & 分享按钮 */}
{
(siteConfig.sharePoster || (sponsorConfig.showButtonInPost && siteConfig.pages.sponsor)) && (
<div class="mb-6 rounded-xl onload-animation">
<div class="p-6 bg-[var(--license-block-bg)] rounded-xl">
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="flex items-center gap-3 flex-1">
<div class="h-12 w-12 rounded-lg bg-[var(--primary)] flex items-center justify-center text-white dark:text-black/70 flex-shrink-0">
<Icon
name={sponsorConfig.showButtonInPost &&
siteConfig.pages.sponsor
? "material-symbols:favorite"
: "material-symbols:share"}
class="text-2xl"
/>
</div>
<div>
<h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
{
sponsorConfig.showButtonInPost && siteConfig.pages.sponsor
? i18n(I18nKey.sponsorButton)
: i18n(I18nKey.shareOnSocial)
}
</h3>
<p class="text-sm text-neutral-600 dark:text-neutral-400">
{
sponsorConfig.showButtonInPost && siteConfig.pages.sponsor
? i18n(I18nKey.sponsorButtonText)
: i18n(I18nKey.shareOnSocialDescription)
}
</p>
</div>
</div>
<div class="flex items-center gap-3">
{
siteConfig.sharePoster && (
<SharePoster
client:load
title={entry.data.title}
author={profileConfig.name}
description={entry.data.description || entry.data.title}
pubDate={formatDateToYYYYMMDD(entry.data.published)}
coverImage={posterCoverUrl}
url={Astro.url.href}
siteTitle={siteConfig.title}
avatar={profileConfig.avatar}
/>
)
}
{
sponsorConfig.showButtonInPost && siteConfig.pages.sponsor && (
<a
href={url("/sponsor/")}
class="inline-flex items-center gap-2 px-6 py-3 bg-[var(--primary)] text-white dark:text-black/70 rounded-lg font-medium hover:bg-[var(--primary)]/80 hover:scale-105 active:scale-95 transition-all whitespace-nowrap"
>
<span>{i18n(I18nKey.sponsor)}</span>
<Icon name="fa6-solid:arrow-right" class="text-sm" />
</a>
)
}
</div>
</div>
</div>
</div>
)
}
{
licenseConfig.enable && (
<License
title={entry.data.title}
id={entry.id}
pubDate={entry.data.published}
author={entry.data.author}
sourceLink={entry.data.sourceLink}
licenseName={entry.data.licenseName}
licenseUrl={entry.data.licenseUrl}
class="mb-6 rounded-xl license-container onload-animation"
/>
)
}
</div>
</div>
<!-- 上次编辑时间 -->
{
siteConfig.showLastModified && (() => {
const lastModified = dayjs(
entry.data.updated || entry.data.published
);
const now = dayjs();
const daysDiff = now.diff(lastModified, "day");
// 使用用户定义的阈值如果没有定义则默认为1天
const outdatedThreshold = siteConfig.outdatedThreshold ?? 1;
const shouldShowOutdatedCard = daysDiff >= outdatedThreshold;
return shouldShowOutdatedCard ? (
<div class="card-base p-6 mb-4">
<div class="flex items-center gap-2">
<div class="transition h-9 w-9 rounded-lg overflow-hidden relative flex items-center justify-center mr-0">
<Icon
name="material-symbols:history-rounded"
class="text-4xl text-[var(--primary)] transition-transform group-hover:translate-x-0.5 bg-[var(--enter-btn-bg)] p-2 rounded-md"
/>
</div>
{(() => {
const dateStr = lastModified.format("YYYY-MM-DD");
const isOutdated = daysDiff >= 1;
return (
<div class="flex flex-col gap-0.1">
<div class="text-[1.0rem] leading-tight text-black/75 dark:text-white/75">
{`${i18n(I18nKey.lastModifiedPrefix)}${dateStr}${
isOutdated
? `${i18n(I18nKey.lastModifiedDaysAgo).replace("{days}", daysDiff.toString())}`
: ""
}`}
</div>
{isOutdated && (
<p class="text-[0.8rem] leading-tight text-black/75 dark:text-white/75">
{i18n(I18nKey.lastModifiedOutdated)}
</p>
)}
</div>
);
})()}
</div>
</div>
) : null;
})()
}
<div
class="flex flex-col md:flex-row justify-between mb-4 gap-4 overflow-hidden w-full"
>
<a
href={entry.data.nextSlug ? getPostUrlBySlug(entry.data.nextSlug) : "#"}
class:list={[
"w-full font-bold overflow-hidden active:scale-95",
{ "pointer-events-none": !entry.data.nextSlug },
]}
>
{
entry.data.nextSlug && (
<div class="btn-card rounded-2xl w-full h-[3.75rem] max-w-full px-4 flex items-center !justify-start gap-4">
<Icon
name="material-symbols:chevron-left-rounded"
class="text-[2rem] text-[var(--primary)]"
/>
<div class="overflow-hidden transition overflow-ellipsis whitespace-nowrap max-w-[calc(100%_-_3rem)] text-black/75 dark:text-white/75">
{entry.data.nextTitle}
</div>
</div>
)
}
</a>
<a
href={entry.data.prevSlug ? getPostUrlBySlug(entry.data.prevSlug) : "#"}
class:list={[
"w-full font-bold overflow-hidden active:scale-95",
{ "pointer-events-none": !entry.data.prevSlug },
]}
>
{
entry.data.prevSlug && (
<div class="btn-card rounded-2xl w-full h-[3.75rem] max-w-full px-4 flex items-center !justify-end gap-4">
<div class="overflow-hidden transition overflow-ellipsis whitespace-nowrap max-w-[calc(100%_-_3rem)] text-black/75 dark:text-white/75">
{entry.data.prevTitle}
</div>
<Icon
name="material-symbols:chevron-right-rounded"
class="text-[2rem] text-[var(--primary)]"
/>
</div>
)
}
</a>
</div>
<!-- 评论 -->
{entry.data.comment && <Comment post={entry} />}
</MainGridLayout>

16
src/pages/robots.txt.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { APIRoute } from "astro";
const robotsTxt = `
User-agent: *
Disallow: /_astro/
Sitemap: ${new URL("sitemap-index.xml", import.meta.env.SITE).href}
`.trim();
export const GET: APIRoute = () => {
return new Response(robotsTxt, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
},
});
};

159
src/pages/rss.astro Normal file
View File

@@ -0,0 +1,159 @@
---
import MainGridLayout from "@layouts/MainGridLayout.astro";
import { getSortedPosts } from "@utils/content-utils";
import { formatDateToYYYYMMDD } from "@utils/date-utils";
import { Icon } from "astro-icon/components";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
const posts = await getSortedPosts();
const recentPosts = posts.slice(0, 6);
---
<MainGridLayout title={i18n(I18nKey.rss)} description={i18n(I18nKey.rssDescription)}>
<div class="onload-animation">
<!-- RSS 标题和介绍 -->
<div class="card-base rounded-[var(--radius-large)] p-8 mb-6">
<div class="text-center">
<div class="inline-flex items-center justify-center w-16 h-16 bg-[var(--primary)] rounded-2xl mb-4">
<Icon name="material-symbols:rss-feed" class="text-white text-3xl" />
</div>
<h1 class="text-3xl font-bold text-[var(--primary)] mb-3">{i18n(I18nKey.rss)}</h1>
<p class="text-75 max-w-2xl mx-auto">
{i18n(I18nKey.rssSubtitle)}
</p>
</div>
</div>
<!-- RSS 链接复制区域 -->
<div class="card-base rounded-[var(--radius-large)] p-6 mb-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="flex items-center">
<div class="w-12 h-12 bg-[var(--primary)] rounded-xl flex items-center justify-center mr-4">
<Icon name="material-symbols:link" class="text-white text-xl" />
</div>
<div>
<h3 class="font-semibold text-90 mb-1">{i18n(I18nKey.rssLink)}</h3>
<p class="text-sm text-75">{i18n(I18nKey.rssCopyToReader)}</p>
</div>
</div>
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
<code class="bg-[var(--card-bg)] px-3 py-2 rounded-lg text-sm font-mono text-75 border border-[var(--line-divider)] break-all">
{Astro.site}rss.xml
</code>
<button
id="copy-rss-btn"
class="px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-80 transition-all duration-200 font-medium text-sm whitespace-nowrap"
data-url={`${Astro.site}rss.xml`}
data-copied-text={i18n(I18nKey.rssCopied)}
data-failed-text={i18n(I18nKey.rssCopyFailed)}
>
{i18n(I18nKey.rssCopyLink)}
</button>
</div>
</div>
</div>
<!-- 最新文章预览 -->
<div class="card-base rounded-[var(--radius-large)] p-6 mb-6">
<h2 class="text-xl font-bold text-90 mb-4 flex items-center">
<Icon name="material-symbols:article" class="mr-2 text-[var(--primary)]" />
{i18n(I18nKey.rssLatestPosts)}
</h2>
<div class="space-y-4">
{recentPosts.map((post) => (
<article class="bg-[var(--card-bg)] rounded-xl p-4 border border-[var(--line-divider)] hover:border-[var(--primary)] transition-all duration-300">
<h3 class="text-lg font-semibold text-90 mb-2 hover:text-[var(--primary)] transition-colors">
<a href={`/posts/${post.id}/`} class="hover:underline">
{post.data.title}
</a>
</h3>
{post.data.description && (
<p class="text-75 mb-3 line-clamp-2">
{post.data.description}
</p>
)}
<div class="flex items-center gap-4 text-sm text-60">
<time datetime={post.data.published.toISOString()} class="text-75">
{formatDateToYYYYMMDD(post.data.published)}
</time>
</div>
</article>
))}
</div>
</div>
<!-- RSS 说明 -->
<div class="card-base rounded-[var(--radius-large)] p-6">
<h2 class="text-xl font-bold text-90 mb-4 flex items-center">
<Icon name="material-symbols:help-outline" class="mr-2 text-[var(--primary)]" />
{i18n(I18nKey.rssWhatIsRSS)}
</h2>
<div class="text-75 space-y-3">
<p>
{i18n(I18nKey.rssWhatIsRSSDescription)}
</p>
<ul class="list-disc list-inside space-y-1 ml-4">
<li>{i18n(I18nKey.rssBenefit1)}</li>
<li>{i18n(I18nKey.rssBenefit2)}</li>
<li>{i18n(I18nKey.rssBenefit3)}</li>
<li>{i18n(I18nKey.rssBenefit4)}</li>
</ul>
<p class="text-sm">
{i18n(I18nKey.rssHowToUse)}
</p>
</div>
</div>
</div>
<script>
function initRssCopy() {
const copyBtn = document.getElementById('copy-rss-btn');
if (!copyBtn) return;
// 移除旧的事件监听器(通过克隆节点的方式)
const newBtn = copyBtn.cloneNode(true);
copyBtn.parentNode?.replaceChild(newBtn, copyBtn);
// 绑定新的事件监听器
newBtn.addEventListener('click', async function() {
const url = this.getAttribute('data-url');
if (!url) return;
try {
await navigator.clipboard.writeText(url);
const originalText = this.textContent;
this.textContent = this.getAttribute('data-copied-text') || '';
this.style.backgroundColor = 'var(--success-color, #10b981)';
setTimeout(() => {
this.textContent = originalText;
this.style.backgroundColor = '';
}, 2000);
} catch (err) {
console.error('复制失败:', err);
const originalText = this.textContent;
this.textContent = this.getAttribute('data-failed-text') || '';
setTimeout(() => {
this.textContent = originalText;
}, 2000);
}
});
}
// 立即初始化(支持直接访问页面和刷新)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initRssCopy);
} else {
// DOM 已加载,直接初始化
setTimeout(initRssCopy, 0);
}
// 支持 Swup 客户端路由(如果存在)
if (typeof window !== 'undefined' && (window as any).swup) {
(window as any).swup.hooks.on('content:replace', () => {
// 延迟执行以确保 DOM 已更新
setTimeout(initRssCopy, 100);
});
}
</script>
</MainGridLayout>

50
src/pages/rss.xml.ts Normal file
View File

@@ -0,0 +1,50 @@
import rss from "@astrojs/rss";
import { getSortedPosts } from "@utils/content-utils";
import { formatDateI18nWithTime } from "@utils/date-utils";
import { url } from "@utils/url-utils";
import type { APIContext } from "astro";
import MarkdownIt from "markdown-it";
import sanitizeHtml from "sanitize-html";
import { profileConfig, siteConfig } from "@/config";
import pkg from "../../package.json";
const parser = new MarkdownIt();
function stripInvalidXmlChars(str: string): string {
return str.replace(
// biome-ignore lint/suspicious/noControlCharactersInRegex: https://www.w3.org/TR/xml/#charsets
/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F\uFDD0-\uFDEF\uFFFE\uFFFF]/g,
"",
);
}
export async function GET(context: APIContext) {
const blog = await getSortedPosts();
return rss({
title: siteConfig.title,
description: siteConfig.subtitle || "No description",
site: context.site ?? "https://firefly.cuteleaf.cn",
customData: `
<language>${siteConfig.lang}</language>
<templateTheme>Firefly</templateTheme>
<templateThemeVersion>${pkg.version}</templateThemeVersion>
<templateThemeUrl>https://github.com/CuteLeaf/Firefly</templateThemeUrl>
<lastBuildDate>${formatDateI18nWithTime(new Date())}</lastBuildDate>`,
items: blog.map((post) => {
const content =
typeof post.body === "string" ? post.body : String(post.body || "");
const cleanedContent = stripInvalidXmlChars(content);
return {
title: post.data.title,
author: post.data?.author || profileConfig.name,
pubDate: post.data.published,
description: post.data.description || "",
link: url(`/posts/${post.id}/`),
content: sanitizeHtml(parser.render(cleanedContent), {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
}),
};
}),
});
}

42
src/pages/search.astro Normal file
View File

@@ -0,0 +1,42 @@
---
import AdvancedSearch from "@components/pages/AdvancedSearch.svelte";
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import MainGridLayout from "@layouts/MainGridLayout.astro";
import { url } from "@/utils/url-utils";
const title = i18n(I18nKey.search);
const description = "";
---
<MainGridLayout title={title} description={description}>
<div class="min-h-[80vh]">
<AdvancedSearch client:load title={title} description={description} />
</div>
</MainGridLayout>
{import.meta.env.PROD && (
<script is:inline define:vars={{ scriptUrl: url('/pagefind/pagefind.js') }}>
if (!window.pagefind) {
async function loadPagefind() {
const url = scriptUrl.replace(/\/$/, "")
try {
const response = await fetch(url, { method: 'HEAD' });
if (response.status !== 200) {
return;
}
const pagefind = await import(scriptUrl);
await pagefind.options({
"excerptLength": 20
});
window.pagefind = pagefind;
window.dispatchEvent(new Event('pagefindready'));
} catch (error) {
console.warn("Pagefind script not found or failed to load.");
window.dispatchEvent(new Event('pagefindloaderror'));
}
}
loadPagefind();
}
</script>
)}

185
src/pages/sponsor.astro Normal file
View File

@@ -0,0 +1,185 @@
---
import Icon from "@/components/misc/Icon.astro";
import { siteConfig, sponsorConfig } from "@/config";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import MainGridLayout from "@/layouts/MainGridLayout.astro";
import { url } from "@/utils/url-utils";
// 检查页面是否启用
if (!siteConfig.pages.sponsor) {
return Astro.redirect("/404/");
}
const title = sponsorConfig.title || i18n(I18nKey.sponsorTitle);
const description =
sponsorConfig.description || i18n(I18nKey.sponsorDescription);
const enabledMethods = sponsorConfig.methods.filter((method) => method.enabled);
const sponsors = sponsorConfig.sponsors || [];
const showSponsorsList = sponsorConfig.showSponsorsList !== false;
---
<MainGridLayout title={title} description={description}>
<div class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full">
<!-- 页面标题和描述 -->
<div class="mb-8">
<div class="flex items-center gap-3 mb-3">
<div class="h-8 w-8 rounded-lg bg-[var(--primary)] flex items-center justify-center text-white dark:text-black/70">
<Icon icon="material-symbols:favorite" class="text-[1.5rem]"></Icon>
</div>
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100">
{title}
</h1>
</div>
{description && (
<p class="text-base text-neutral-600 dark:text-neutral-400 leading-relaxed mb-4">
{description}
</p>
)}
{sponsorConfig.usage && (
<div class="mb-8 p-4 rounded-lg bg-[var(--primary)]/8 dark:bg-[var(--btn-regular-bg)] border border-[var(--primary)]/30 dark:border-none backdrop-blur-sm shadow-sm usage-info-box">
<div class="flex items-start gap-2">
<Icon icon="material-symbols:info-outline" class="text-[var(--primary)] text-lg flex-shrink-0 mt-0.5" />
<p class="text-sm text-neutral-700 dark:text-neutral-200 leading-relaxed">
<span class="font-semibold text-[var(--primary)]"></span>
{sponsorConfig.usage}
</p>
</div>
</div>
)}
</div>
<!-- 赞助方式 -->
<div class="mb-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
{enabledMethods.map((method) => (
<div class="flex flex-col items-center p-6 rounded-lg bg-neutral-50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-800 hover:border-[var(--primary)] transition-colors">
<!-- 图标和名称 -->
<div class="flex items-center gap-3 mb-4">
{method.icon && (
<Icon icon={method.icon} size="2xl" class="text-neutral-900 dark:text-neutral-100 transition-colors" />
)}
<h3 class="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{method.name}
</h3>
</div>
<!-- 描述 -->
{method.description && (
<p class="text-sm text-neutral-600 dark:text-neutral-400 mb-4 text-center">
{method.description}
</p>
)}
<!-- 二维码或链接 -->
{method.qrCode && (
<div class="relative w-full max-w-[200px] aspect-square bg-white rounded-lg p-4 shadow-md mb-4">
<img
src={url(method.qrCode)}
alt={`${method.name} ${i18n(I18nKey.scanToSponsor)}`}
class="w-full h-full object-contain"
loading="lazy"
/>
</div>
)}
{method.link && (
<a
href={method.link}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white dark:text-black/70 rounded-lg font-medium hover:bg-[var(--primary)]/90 active:scale-95 transition-all"
>
<span>{i18n(I18nKey.sponsorGoTo)}</span>
<Icon icon="fa6-solid:arrow-up-right-from-square" class="text-sm" />
</a>
)}
</div>
))}
</div>
</div>
<!-- 赞助者列表 -->
{showSponsorsList && (
<div>
<div class="flex items-center gap-3 mb-6">
<Icon icon="material-symbols:emoji-people-rounded" size="xl" class="text-[var(--primary)]" />
<h2 class="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{i18n(I18nKey.sponsorList)}
</h2>
</div>
{sponsors.length > 0 ? (
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
{sponsors.map((sponsor) => (
<div class="relative p-5 rounded-lg bg-neutral-50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-800 hover:border-[var(--primary)] transition-all duration-200 hover:shadow-md">
<!-- 内容 -->
<div class="flex flex-col h-full">
<!-- 头部:姓名和金额 -->
<div class="flex items-center justify-between mb-3">
<span class="font-semibold text-lg text-neutral-900 dark:text-neutral-100">
{sponsor.name}
</span>
{sponsor.amount && (
<span class="inline-flex items-center px-3 py-1 text-sm font-bold text-[var(--primary)] bg-[var(--primary)]/10 dark:bg-[var(--primary)]/20 rounded-full">
{sponsor.amount}
</span>
)}
</div>
<!-- 留言 -->
{sponsor.message && (
<p class="text-sm text-neutral-600 dark:text-neutral-400 mb-3 leading-relaxed flex-1">
{sponsor.message}
</p>
)}
<!-- 底部:日期 -->
{sponsor.date && (
<div class="flex items-center gap-1.5 mt-auto pt-3 border-t border-neutral-200 dark:border-neutral-800">
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4 text-neutral-400 dark:text-neutral-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span class="text-xs text-neutral-500 dark:text-neutral-500">
{new Date(sponsor.date).toLocaleDateString()}
</span>
</div>
)}
</div>
</div>
))}
</div>
) : (
<div class="text-center py-12 text-neutral-500 dark:text-neutral-500">
<p>{i18n(I18nKey.sponsorEmpty)}</p>
</div>
)}
</div>
)}
</div>
</div>
</MainGridLayout>
<style>
/* 透明模式下使用半透明背景 */
:global(body.wallpaper-transparent .usage-info-box) {
background-color: rgba(var(--primary-rgb, 70, 130, 180), 0.15) !important;
}
:global(:root.dark body.wallpaper-transparent .usage-info-box) {
background-color: oklch(from var(--primary) l c h / 0.15) !important;
}
</style>