first commit
This commit is contained in:
73
src/pages/404.astro
Normal file
73
src/pages/404.astro
Normal 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
70
src/pages/[...page].astro
Normal 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
25
src/pages/about.astro
Normal 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>
|
||||
13
src/pages/api/calendar.json.ts
Normal file
13
src/pages/api/calendar.json.ts
Normal 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
13
src/pages/archive.astro
Normal 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
251
src/pages/bangumi.astro
Normal 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
212
src/pages/friends.astro
Normal 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
90
src/pages/guestbook.astro
Normal 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>
|
||||
354
src/pages/og/[...slug].png.ts
Normal file
354
src/pages/og/[...slug].png.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
393
src/pages/posts/[...slug].astro
Normal file
393
src/pages/posts/[...slug].astro
Normal 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
16
src/pages/robots.txt.ts
Normal 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
159
src/pages/rss.astro
Normal 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
50
src/pages/rss.xml.ts
Normal 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
42
src/pages/search.astro
Normal 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
185
src/pages/sponsor.astro
Normal 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>
|
||||
|
||||
Reference in New Issue
Block a user