mirror of
https://github.com/StepanovPlaton/AboutMe.git
synced 2026-04-04 04:40:51 +04:00
Initial commit
This commit is contained in:
74
src/pages/404.astro
Normal file
74
src/pages/404.astro
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
import { i18n } from "@i18n/translation";
|
||||
import GridLayout from "@layouts/grid.astro";
|
||||
---
|
||||
|
||||
<GridLayout title={i18n(I18nKey.notFound)} description={i18n(I18nKey.notFoundDescription)}>
|
||||
<div class="flex w-full rounded-(--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-(--primary) opacity-20 mb-4">
|
||||
{i18n(I18nKey.notFound)}
|
||||
</div>
|
||||
|
||||
<!-- 404 图标 -->
|
||||
<div class="mb-6">
|
||||
<Icon name="material-symbols:error-outline" class="text-6xl text-(--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-(--primary) text-white rounded-(--radius-large) hover:bg-(--primary-dark) transition-colors duration-300 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-(--primary)" />
|
||||
</div>
|
||||
<div class="absolute bottom-4 right-4 opacity-10">
|
||||
<Icon name="material-symbols:search-off" class="text-4xl text-(--primary)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridLayout>
|
||||
|
||||
<style>
|
||||
/* 添加一些动画效果 */
|
||||
.card-base {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
translate: 0 30px;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 按钮悬停效果 */
|
||||
a:hover {
|
||||
translate: 0 -2px;
|
||||
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.3);
|
||||
}
|
||||
</style>
|
||||
112
src/pages/[...menu].astro
Normal file
112
src/pages/[...menu].astro
Normal file
@@ -0,0 +1,112 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
import { type NavbarLink } from "@/types/config";
|
||||
import { navbarConfig } from "@/config";
|
||||
import { LinkPresets } from "@constants/link-presets";
|
||||
import { url } from "@utils/url";
|
||||
import GridLayout from "@layouts/grid.astro";
|
||||
import BackwardButton from "@components/backwardButton.astro";
|
||||
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return navbarConfig.links
|
||||
.filter(link => typeof link !== "number" && link.children && link.children.length > 0)
|
||||
.map(link => {
|
||||
const navLink = link as NavbarLink;
|
||||
// 提取 slug,去掉前后的斜杠
|
||||
const slug = navLink.url.replace(/^\/|\/$/g, '');
|
||||
return {
|
||||
params: { menu: slug },
|
||||
props: { link: navLink }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
interface Props {
|
||||
link: NavbarLink;
|
||||
}
|
||||
|
||||
const { link } = Astro.props;
|
||||
|
||||
// 获取子链接,并将 LinkPreset 转换为完整的 NavbarLink 对象
|
||||
const childrenLinks = link.children || [];
|
||||
|
||||
const processedLinks = childrenLinks.map(child => {
|
||||
if (typeof child === "number") {
|
||||
return LinkPresets[child];
|
||||
}
|
||||
return child;
|
||||
});
|
||||
|
||||
const pageTitle = link.name;
|
||||
const pageDescription = link.description || "";
|
||||
---
|
||||
|
||||
<GridLayout title={pageTitle} description={pageDescription}>
|
||||
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
|
||||
<div class="card-base z-10 px-9 py-6 relative w-full">
|
||||
<BackwardButton currentPath={Astro.url.pathname} />
|
||||
<!-- 标题 -->
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-3">
|
||||
{pageTitle}
|
||||
</h1>
|
||||
{pageDescription && (
|
||||
<p class="text-neutral-600 dark:text-neutral-400">
|
||||
{pageDescription}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
<!-- 链接网格 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{processedLinks.map(child => (
|
||||
<a
|
||||
href={child.external ? child.url : url(child.url)}
|
||||
target={child.external ? "_blank" : null}
|
||||
class="menu-card group flex flex-col items-center justify-center p-8 rounded-xl border transition-all duration-300"
|
||||
>
|
||||
<div class="icon-wrapper w-16 h-16 flex items-center justify-center rounded-full mb-4 transition-all duration-300">
|
||||
{child.icon && <Icon name={child.icon} class="text-3xl" />}
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-neutral-900 dark:text-neutral-100 group-hover:text-(--primary) transition-colors duration-300 text-center">
|
||||
{child.name}
|
||||
</h2>
|
||||
{child.description && (
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400 text-center mt-2 line-clamp-2">
|
||||
{child.description}
|
||||
</p>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridLayout>
|
||||
|
||||
<style>
|
||||
.menu-card {
|
||||
cursor: pointer;
|
||||
background-color: none;
|
||||
border-color: var(--line-divider);
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
background-color: color-mix(in oklch, var(--primary), transparent 85%);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.menu-card:hover {
|
||||
background-color: color-mix(in oklch, var(--primary), transparent 95%);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 20px color-mix(in oklch, var(--primary), transparent 80%);
|
||||
translate: 0 -4px;
|
||||
}
|
||||
|
||||
.menu-card:hover .icon-wrapper {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
box-shadow: 0 0 15px color-mix(in oklch, var(--primary), transparent 50%);
|
||||
}
|
||||
</style>
|
||||
29
src/pages/[...page].astro
Normal file
29
src/pages/[...page].astro
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
|
||||
import type { GetStaticPaths } from "astro";
|
||||
|
||||
import { PAGE_SIZE } from "@constants/constants";
|
||||
import { getSortedPosts } from "@utils/content";
|
||||
import Pagination from "@components/pagination.astro";
|
||||
import PostPage from "@components/postPage.astro";
|
||||
import GridLayout from "@layouts/grid.astro";
|
||||
|
||||
|
||||
export const getStaticPaths = (async ({ paginate }) => {
|
||||
const allBlogPosts = await getSortedPosts();
|
||||
return paginate(allBlogPosts, { pageSize: PAGE_SIZE });
|
||||
}) satisfies GetStaticPaths;
|
||||
// https://github.com/withastro/astro/issues/6507#issuecomment-1489916992
|
||||
|
||||
|
||||
const { page } = Astro.props;
|
||||
|
||||
const len = page.data.length;
|
||||
---
|
||||
|
||||
<GridLayout>
|
||||
<PostPage page={page}></PostPage>
|
||||
<Pagination class="mx-auto onload-animation-up" page={page} style={`animation-delay: ${(len)*50}ms`}></Pagination>
|
||||
</GridLayout>
|
||||
35
src/pages/about.astro
Normal file
35
src/pages/about.astro
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
|
||||
import { getEntry, render } from "astro:content";
|
||||
|
||||
import { LinkPresets } from "@constants/link-presets";
|
||||
import { LinkPreset } from "@/types/config";
|
||||
import Markdown from "@components/common/markdown.astro";
|
||||
import GridLayout from "@layouts/grid.astro";
|
||||
import BackwardButton from "@components/backwardButton.astro";
|
||||
|
||||
|
||||
const pageTitle = LinkPresets[LinkPreset.About].name;
|
||||
const pageDescription = LinkPresets[LinkPreset.About].description;
|
||||
|
||||
const aboutPost = await getEntry("spec", "about");
|
||||
|
||||
if (!aboutPost) {
|
||||
throw new Error("About page content not found");
|
||||
}
|
||||
|
||||
const { Content } = await render(aboutPost);
|
||||
---
|
||||
|
||||
<GridLayout title={pageTitle} description={pageDescription}>
|
||||
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
|
||||
<div class="card-base z-10 px-9 py-6 relative w-full ">
|
||||
<BackwardButton currentPath={Astro.url.pathname} />
|
||||
<Markdown class="mt-2">
|
||||
<Content />
|
||||
</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
</GridLayout>
|
||||
131
src/pages/albums.astro
Normal file
131
src/pages/albums.astro
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
import { LinkPresets } from "@constants/link-presets";
|
||||
import { LinkPreset } from "@/types/config";
|
||||
import { sortedAlbums } from "@utils/albums";
|
||||
import { i18n } from "@i18n/translation";
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
import GridLayout from "@layouts/grid.astro";
|
||||
import BackwardButton from "@components/backwardButton.astro";
|
||||
|
||||
|
||||
const pageTitle = LinkPresets[LinkPreset.Albums].name;
|
||||
const pageDescription = LinkPresets[LinkPreset.Albums].description;
|
||||
|
||||
// 获取所有相册
|
||||
const albumsData = sortedAlbums.filter(album => album.visible);
|
||||
---
|
||||
|
||||
<GridLayout title={pageTitle} description={pageDescription}>
|
||||
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
|
||||
<div class="card-base z-10 px-9 py-6 relative w-full">
|
||||
<BackwardButton currentPath={Astro.url.pathname} />
|
||||
<!-- 页面标题 -->
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-3">
|
||||
{pageTitle}
|
||||
</h1>
|
||||
<p class="text-neutral-600 dark:text-neutral-400">
|
||||
{pageDescription}
|
||||
</p>
|
||||
</header>
|
||||
<!-- 相册网格 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{albumsData.map(album => (
|
||||
<article
|
||||
class="album-card group bg-white dark:bg-neutral-900 rounded-lg overflow-hidden shadow-sm hover:shadow-md transition-all duration-300 border border-neutral-200 dark:border-neutral-800"
|
||||
>
|
||||
<a href={`/albums/${album.id}/`} class="block">
|
||||
<!-- 封面图片 -->
|
||||
<div class="aspect-4/3 overflow-hidden bg-neutral-100 dark:bg-neutral-700">
|
||||
<img
|
||||
src={album.cover}
|
||||
alt={album.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<!-- 相册信息 -->
|
||||
<div class="p-4">
|
||||
<h3 class="font-bold text-lg text-neutral-900 dark:text-neutral-100 mb-2 group-hover:text-(--primary) transition-colors">
|
||||
{album.title}
|
||||
</h3>
|
||||
{album.description && (
|
||||
<p class="text-neutral-600 dark:text-neutral-400 text-sm mb-3 line-clamp-2">
|
||||
{album.description}
|
||||
</p>
|
||||
)}
|
||||
<!-- 元数据 -->
|
||||
<div class="flex items-center justify-between text-xs text-neutral-500 dark:text-neutral-500">
|
||||
<div class="flex items-center gap-4">
|
||||
<span>{album.photos.length} {album.photos.length > 1 ? i18n(I18nKey.albumsPhotosCount) : i18n(I18nKey.albumsPhotoCount)}</span>
|
||||
{album.location && (
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{album.location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<time>{new Date(album.date).toLocaleDateString('zh-CN')}</time>
|
||||
</div>
|
||||
<!-- 标签 -->
|
||||
{album.tags && album.tags.length > 0 && (
|
||||
<div class="flex flex-wrap gap-1 mt-3">
|
||||
{album.tags.slice(0, 3).map(tag => (
|
||||
<span class="btn-regular h-6 text-xs px-2 rounded-lg">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{album.tags.length > 3 && (
|
||||
<span class="btn-regular h-6 text-xs px-2 rounded-lg">
|
||||
+{album.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<!-- 空状态 -->
|
||||
{albumsData.length === 0 && (
|
||||
<div class="text-center py-12">
|
||||
<div class="text-neutral-400 dark:text-neutral-600 mb-4">
|
||||
<svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-neutral-900 dark:text-neutral-100 mb-2">
|
||||
{i18n(I18nKey.albumsEmpty)}
|
||||
</h3>
|
||||
<p class="text-neutral-600 dark:text-neutral-400">
|
||||
{i18n(I18nKey.albumsEmptyDesc)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GridLayout>
|
||||
|
||||
<style>
|
||||
.album-card:hover {
|
||||
translate: 0 -2px;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
translate: 0 20px;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.album-card {
|
||||
animation: fadeInUp 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
139
src/pages/albums/[id]/index.astro
Normal file
139
src/pages/albums/[id]/index.astro
Normal file
@@ -0,0 +1,139 @@
|
||||
---
|
||||
import type { AlbumGroup } from "@utils/albums";
|
||||
import { sortedAlbums } from "@utils/albums";
|
||||
import { i18n } from "@i18n/translation";
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
import GridLayout from "@layouts/grid.astro";
|
||||
import BackwardButton from "@components/backwardButton.astro";
|
||||
|
||||
|
||||
export const getStaticPaths = async () => {
|
||||
return sortedAlbums.filter(album => album.visible).map((album) => ({
|
||||
params: { id: album.id },
|
||||
props: { album },
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
interface Props {
|
||||
album: AlbumGroup;
|
||||
}
|
||||
|
||||
const { album } = Astro.props;
|
||||
|
||||
if (!album) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
---
|
||||
|
||||
<GridLayout title={album.title} description={album.description || album.title}>
|
||||
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
|
||||
<div class="card-base z-10 px-9 py-6 relative w-full">
|
||||
<BackwardButton href="/albums/" text={i18n(I18nKey.albumsBackToList)} />
|
||||
<!-- 相册标题信息 -->
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-3">
|
||||
{album.title}
|
||||
</h1>
|
||||
{album.description && (
|
||||
<p class="text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{album.description}
|
||||
</p>
|
||||
)}
|
||||
<!-- 相册元数据 -->
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm text-neutral-500 dark:text-neutral-500">
|
||||
<span>{album.photos.length} {album.photos.length > 1 ? i18n(I18nKey.albumsPhotosCount) : i18n(I18nKey.albumsPhotoCount)}</span>
|
||||
<time>{new Date(album.date).toLocaleDateString('zh-CN')}</time>
|
||||
{album.location && (
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{album.location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<!-- 标签 -->
|
||||
{album.tags && album.tags.length > 0 && (
|
||||
<div class="flex flex-wrap gap-2 mt-4">
|
||||
{album.tags.map(tag => (
|
||||
<span class="btn-regular h-8 text-sm px-3 rounded-lg">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<!-- 照片网格 -->
|
||||
<div
|
||||
class={`photo-gallery moment-images ${album.layout === 'masonry' ? 'masonry-layout' : 'grid-layout'}`}
|
||||
data-layout={album.layout || 'grid'}
|
||||
data-columns={album.columns || 3}
|
||||
>
|
||||
{album.photos.map((photo, index) => (
|
||||
<figure class="photo-item group relative overflow-hidden rounded-lg">
|
||||
<a
|
||||
href={photo.src}
|
||||
data-fancybox="gallery"
|
||||
data-caption={photo.alt}
|
||||
data-no-swup
|
||||
class="block"
|
||||
>
|
||||
<img
|
||||
src={photo.src}
|
||||
alt={photo.alt}
|
||||
class="photo-image w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 cursor-pointer"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridLayout>
|
||||
|
||||
<style>
|
||||
.grid-layout {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.grid-layout[data-columns="2"] {
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
.grid-layout[data-columns="3"] {
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
}
|
||||
|
||||
.grid-layout[data-columns="4"] {
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.masonry-layout {
|
||||
columns: 2;
|
||||
column-gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.masonry-layout {
|
||||
columns: 3;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.masonry-layout[data-columns="4"] {
|
||||
columns: 4;
|
||||
}
|
||||
}
|
||||
|
||||
.masonry-layout .photo-item {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.grid-layout .photo-container {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
</style>
|
||||
360
src/pages/anime.astro
Normal file
360
src/pages/anime.astro
Normal file
@@ -0,0 +1,360 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
|
||||
import { LinkPresets } from "@constants/link-presets";
|
||||
import { LinkPreset } from "@/types/config";
|
||||
import { siteConfig } from "@/config";
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
import { i18n } from "@i18n/translation";
|
||||
import ImageWrapper from "@components/common/imageWrapper.astro";
|
||||
import GridLayout from "@layouts/grid.astro";
|
||||
import BackwardButton from "@components/backwardButton.astro";
|
||||
|
||||
|
||||
const pageTitle = LinkPresets[LinkPreset.Anime].name;
|
||||
const pageDescription = LinkPresets[LinkPreset.Anime].description;
|
||||
|
||||
// Bangumi API配置
|
||||
const BANGUMI_USER_ID = siteConfig.bangumi?.userId || "";
|
||||
const BANGUMI_API_BASE = "https://api.bgm.tv";
|
||||
|
||||
// 检查是否配置了有效的 Bangumi 用户 ID
|
||||
const isBangumiConfigured =
|
||||
BANGUMI_USER_ID &&
|
||||
BANGUMI_USER_ID !== "your-bangumi-id" &&
|
||||
BANGUMI_USER_ID !== "your-user-id";
|
||||
|
||||
// 获取单个条目相关人员信息
|
||||
async function fetchSubjectPersons(subjectId: number) {
|
||||
if (!isBangumiConfigured) return [];
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${BANGUMI_API_BASE}/v0/subjects/${subjectId}/persons`,
|
||||
);
|
||||
const data = await response.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch (error) {
|
||||
console.error(`Error fetching subject ${subjectId} persons:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 获取Bangumi收藏列表
|
||||
async function fetchBangumiCollection(
|
||||
userId: string,
|
||||
subjectType: number,
|
||||
type: number,
|
||||
) {
|
||||
if (!isBangumiConfigured) return null;
|
||||
try {
|
||||
let allData: any[] = [];
|
||||
let offset = 0;
|
||||
const limit = 50; // 每页获取的数量
|
||||
let hasMore = true;
|
||||
|
||||
// 循环获取所有数据
|
||||
while (hasMore) {
|
||||
const response = await fetch(
|
||||
`${BANGUMI_API_BASE}/v0/users/${userId}/collections?subject_type=${subjectType}&type=${type}&limit=${limit}&offset=${offset}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Bangumi API error: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// 添加当前页数据到总数据中
|
||||
if (data.data && data.data.length > 0) {
|
||||
allData = [...allData, ...data.data];
|
||||
}
|
||||
if (!data.data || data.data.length < limit) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
offset += limit;
|
||||
}
|
||||
// 防止请求过于频繁
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return { data: allData };
|
||||
} catch (error) {
|
||||
console.error("Error fetching Bangumi data:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取Bangumi数据转换为页面所需格式
|
||||
async function processBangumiData(data: any, status: string) {
|
||||
if (!data || !data.data) return [];
|
||||
|
||||
// 为每个条目获取详细信息
|
||||
const detailedItems = await Promise.all(
|
||||
data.data.map(async (item: any) => {
|
||||
// 获取相关人员信息
|
||||
const subjectPersons = await fetchSubjectPersons(item.subject_id);
|
||||
// 获取年份信息
|
||||
const year = item.subject?.date || "Unknown";
|
||||
// 获取评分
|
||||
const rating = item.rate ? Number.parseFloat(item.rate.toFixed(1)) : 0;
|
||||
// 获取进度信息
|
||||
const progress = item.ep_status || 0;
|
||||
const totalEpisodes = item.subject?.eps || progress;
|
||||
// 从相关人员中获取制作方信息
|
||||
let studio = "Unknown";
|
||||
if (Array.isArray(subjectPersons)) {
|
||||
// 定义筛选优先级顺序
|
||||
const priorities = ["动画制作", "製作", "制作"];
|
||||
for (const relation of priorities) {
|
||||
const match = subjectPersons.find(
|
||||
(person) => person.relation === relation,
|
||||
);
|
||||
if (match?.name) {
|
||||
studio = match.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: item.subject?.name_cn || item.subject?.name || "Unknown Title",
|
||||
status: status,
|
||||
rating: rating,
|
||||
cover: item.subject?.images?.medium || "/assets/anime/default.webp",
|
||||
description: (
|
||||
item.subject?.short_summary ||
|
||||
item.subject?.name_cn ||
|
||||
""
|
||||
).trimStart(),
|
||||
episodes: `${totalEpisodes} episodes`,
|
||||
year: year,
|
||||
genre: item.subject?.tags
|
||||
? item.subject.tags.slice(0, 3).map((tag: any) => tag.name)
|
||||
: ["Unknown"],
|
||||
studio: studio,
|
||||
link: `https://bgm.tv/subject/${item.subject.id}` || "#",
|
||||
progress: progress,
|
||||
totalEpisodes: totalEpisodes,
|
||||
startDate: item.subject?.date || "",
|
||||
endDate: item.subject?.date || "",
|
||||
};
|
||||
}),
|
||||
);
|
||||
return detailedItems;
|
||||
}
|
||||
|
||||
// 获取Bangumi番剧列表
|
||||
const watchingData = await fetchBangumiCollection(BANGUMI_USER_ID, 2, 3);
|
||||
const completedData = await fetchBangumiCollection(BANGUMI_USER_ID, 2, 2);
|
||||
|
||||
const watchingList = watchingData
|
||||
? await processBangumiData(watchingData, "watching")
|
||||
: [];
|
||||
const completedList = completedData
|
||||
? await processBangumiData(completedData, "completed")
|
||||
: [];
|
||||
|
||||
const animeList = [...watchingList, ...completedList];
|
||||
|
||||
// 获取状态的翻译文本和样式
|
||||
function getStatusInfo(status: string) {
|
||||
switch (status) {
|
||||
case "watching":
|
||||
return {
|
||||
text: i18n(I18nKey.animeStatusWatching),
|
||||
class:
|
||||
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
|
||||
icon: "▶",
|
||||
};
|
||||
case "completed":
|
||||
return {
|
||||
text: i18n(I18nKey.animeStatusCompleted),
|
||||
class:
|
||||
"bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
icon: "✓",
|
||||
};
|
||||
case "planned":
|
||||
return {
|
||||
text: i18n(I18nKey.animeStatusPlanned),
|
||||
class:
|
||||
"bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
|
||||
icon: "⏰",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
text: status,
|
||||
class: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
|
||||
icon: "?",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 计算统计数据
|
||||
const stats = {
|
||||
total: animeList.length,
|
||||
watching: animeList.filter((anime) => anime.status === "watching").length,
|
||||
completed: animeList.filter((anime) => anime.status === "completed").length,
|
||||
avgRating: (() => {
|
||||
const ratedAnime = animeList.filter((anime) => anime.rating > 0);
|
||||
if (ratedAnime.length === 0) return "0.0";
|
||||
return (
|
||||
ratedAnime.reduce((sum, anime) => sum + anime.rating, 0) /
|
||||
ratedAnime.length
|
||||
).toFixed(1);
|
||||
})(),
|
||||
};
|
||||
---
|
||||
|
||||
<GridLayout title={pageTitle} description={pageDescription}>
|
||||
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
|
||||
<div class="card-base z-10 px-9 py-6 relative w-full">
|
||||
<BackwardButton currentPath={Astro.url.pathname} />
|
||||
<!-- 页面标题 -->
|
||||
<div class="relative w-full mb-8">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-4xl font-bold text-black/90 dark:text-white/90 mb-2">{i18n(I18nKey.animeTitle)}</h1>
|
||||
<p class="text-black/75 dark:text-white/75">{pageDescription}</p>
|
||||
</div>
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-linear-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-lg p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-2xl">📊</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.total}</div>
|
||||
<div class="text-sm text-blue-600/70 dark:text-blue-400/70">{i18n(I18nKey.animeTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-linear-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 rounded-lg p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-2xl">▶️</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-green-600 dark:text-green-400">{stats.watching}</div>
|
||||
<div class="text-sm text-green-600/70 dark:text-green-400/70">{i18n(I18nKey.animeWatching)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-linear-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 rounded-lg p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-2xl">✅</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-purple-600 dark:text-purple-400">{stats.completed}</div>
|
||||
<div class="text-sm text-purple-600/70 dark:text-purple-400/70">{i18n(I18nKey.animeCompleted)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-linear-to-br from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-800/20 rounded-lg p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-2xl">⭐</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-orange-600 dark:text-orange-400">{stats.avgRating}</div>
|
||||
<div class="text-sm text-orange-600/70 dark:text-orange-400/70">{i18n(I18nKey.animeAvgRating)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 动漫列表 -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-4">
|
||||
{i18n(I18nKey.animeList)}
|
||||
</h2>
|
||||
{!isBangumiConfigured ? (
|
||||
<div class="text-center py-12">
|
||||
<div class="text-5xl mb-4">😢</div>
|
||||
<h3 class="text-xl font-medium text-black/80 dark:text-white/80 mb-2">
|
||||
{i18n(I18nKey.animeEmpty)}
|
||||
</h3>
|
||||
<p class="text-black/60 dark:text-white/60">
|
||||
Please configure your Bangumi account in the config file.
|
||||
</p>
|
||||
</div>
|
||||
) : animeList.length > 0 ? (
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-6">
|
||||
{animeList.map(anime => {
|
||||
const statusInfo = getStatusInfo(anime.status);
|
||||
const progressPercent = anime.totalEpisodes > 0 ? (anime.progress / anime.totalEpisodes) * 100 : 0;
|
||||
return (
|
||||
<div class="group relative bg-(--card-bg) border border-(--line-divider) rounded-(--radius-large) overflow-hidden transition-all duration-300 hover:shadow-lg hover:scale-[1.02]">
|
||||
<!-- 封面区域 - 竖屏比例 -->
|
||||
<div class="relative aspect-2/3 overflow-hidden">
|
||||
<a href={anime.link} target="_blank" rel="noopener noreferrer" class="block w-full h-full">
|
||||
<ImageWrapper
|
||||
src={anime.cover}
|
||||
alt={anime.title}
|
||||
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-linear-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="w-12 h-12 rounded-full bg-white/90 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-gray-800 ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<!-- 状态标签 -->
|
||||
<div class={`absolute top-2 left-2 px-2 py-1 rounded-md text-xs font-medium ${statusInfo.class}`}>
|
||||
<span class="mr-1">{statusInfo.icon}</span>
|
||||
<span>{statusInfo.text}</span>
|
||||
</div>
|
||||
<!-- 评分 -->
|
||||
<div class="absolute top-2 right-2 bg-black/70 text-white px-2 py-1 rounded-md text-xs font-medium flex items-center gap-1">
|
||||
<svg class="w-3 h-3 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
<span>{anime.rating}</span>
|
||||
</div>
|
||||
<!-- 进度条 - 在封面底部 -->
|
||||
{anime.status === 'watching' && (
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/80 to-transparent p-2">
|
||||
<div class="w-full bg-white/20 rounded-full h-1.5 mb-1">
|
||||
<div class="bg-linear-to-r from-emerald-400 to-teal-400 h-1.5 rounded-full transition-all duration-300" style={`width: ${progressPercent}%`}></div>
|
||||
</div>
|
||||
<div class="text-white text-xs font-medium">
|
||||
{anime.progress}/{anime.totalEpisodes} ({Math.round(progressPercent)}%)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<!-- 内容区域 - 紧凑设计 -->
|
||||
<div class="p-3">
|
||||
<h3 class="text-sm font-bold text-black/90 dark:text-white/90 mb-1 line-clamp-2 leading-tight">{anime.title}</h3>
|
||||
<p class="text-black/60 dark:text-white/60 text-xs mb-2 line-clamp-2">{anime.description}</p>
|
||||
<!-- 详细信息 - 更紧凑 -->
|
||||
<div class="space-y-1 text-xs">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-black/50 dark:text-white/50">{i18n(I18nKey.animeYear)}</span>
|
||||
<span class="text-black/70 dark:text-white/70">{anime.year}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-black/50 dark:text-white/50">{i18n(I18nKey.animeStudio)}</span>
|
||||
<span class="text-black/70 dark:text-white/70 truncate ml-2">{anime.studio}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1 mt-2">
|
||||
{anime.genre.map(g => (
|
||||
<span class="px-1.5 py-0.5 bg-(--btn-regular-bg) text-black/70 dark:text-white/70 rounded-sm text-xs">{g}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div class="text-center py-12">
|
||||
<div class="text-5xl mb-4">😢</div>
|
||||
<h3 class="text-xl font-medium text-black/80 dark:text-white/80 mb-2">
|
||||
{i18n(I18nKey.animeEmpty)}
|
||||
</h3>
|
||||
<p class="text-black/60 dark:text-white/60">
|
||||
{i18n(I18nKey.animeEmptyBangumi)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridLayout>
|
||||
35
src/pages/archive.astro
Normal file
35
src/pages/archive.astro
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
import { LinkPresets } from "@constants/link-presets";
|
||||
import { LinkPreset } from "@/types/config";
|
||||
import { getSortedPostsList } from "@utils/content";
|
||||
import ArchivePanel from "@components/archivePanel.svelte";
|
||||
import GridLayout from "@layouts/grid.astro";
|
||||
import BackwardButton from "@components/backwardButton.astro";
|
||||
|
||||
|
||||
const pageTitle = LinkPresets[LinkPreset.Archive].name;
|
||||
const pageDescription = LinkPresets[LinkPreset.Archive].description;
|
||||
|
||||
const sortedPostsList = await getSortedPostsList();
|
||||
---
|
||||
|
||||
<GridLayout title={pageTitle} description={pageDescription}>
|
||||
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
|
||||
<div class="card-base z-10 px-9 py-6 relative w-full ">
|
||||
<BackwardButton currentPath={Astro.url.pathname} />
|
||||
<!-- 标题 -->
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-3">
|
||||
{pageTitle}
|
||||
</h1>
|
||||
{pageDescription && (
|
||||
<p class="text-neutral-600 dark:text-neutral-400">
|
||||
{pageDescription}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
<!-- 归档面板 -->
|
||||
<ArchivePanel sortedPosts={sortedPostsList} client:load></ArchivePanel>
|
||||
</div>
|
||||
</div>
|
||||
</GridLayout>
|
||||
134
src/pages/atom.astro
Normal file
134
src/pages/atom.astro
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
import { getSortedPosts } from "@utils/content";
|
||||
import { formatDateToYYYYMMDD } from "@utils/date";
|
||||
import { i18n } from "@i18n/translation";
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
import GridLayout from "@layouts/grid.astro";
|
||||
|
||||
|
||||
const posts = (await getSortedPosts()).filter((post) => !post.data.encrypted);
|
||||
const recentPosts = posts.slice(0, 6);
|
||||
---
|
||||
|
||||
<GridLayout title={i18n(I18nKey.atom)} description={i18n(I18nKey.atomDescription)}>
|
||||
|
||||
<div class="onload-animation-up">
|
||||
<!-- Atom 标题和介绍 -->
|
||||
<div class="card-base rounded-(--radius-large) p-8 mb-6">
|
||||
<div class="text-center">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-(--primary) rounded-2xl mb-4">
|
||||
<Icon name="material-symbols:rss-feed" class="text-white text-3xl" />
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-(--primary) mb-3">{i18n(I18nKey.atom)}</h1>
|
||||
<p class="text-75 max-w-2xl mx-auto">
|
||||
{i18n(I18nKey.atomSubtitle)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Atom 链接复制区域 -->
|
||||
<div class="card-base rounded-(--radius-large) p-6 mb-6">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-(--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.atomLink)}</h3>
|
||||
<p class="text-sm text-75">{i18n(I18nKey.atomCopyToReader)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<code class="bg-(--card-bg) px-3 py-2 rounded-lg text-sm font-mono text-75 border border-(--line-divider) break-all">
|
||||
{Astro.site}atom.xml
|
||||
</code>
|
||||
<button
|
||||
id="copy-atom-btn"
|
||||
class="px-4 py-2 bg-(--primary) text-white rounded-lg hover:opacity-80 transition-all duration-200 font-medium text-sm whitespace-nowrap"
|
||||
data-url={`${Astro.site}atom.xml`}
|
||||
>
|
||||
{i18n(I18nKey.atomCopyLink)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 最新文章预览 -->
|
||||
<div class="card-base rounded-(--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-(--primary)" />
|
||||
{i18n(I18nKey.atomLatestPosts)}
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
{recentPosts.map((post) => (
|
||||
<article class="bg-(--card-bg) rounded-xl p-4 border border-(--line-divider) hover:border-(--primary) transition-all duration-300">
|
||||
<h3 class="text-lg font-semibold text-90 mb-2 hover:text-(--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>
|
||||
<!-- Atom 说明 -->
|
||||
<div class="card-base rounded-(--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-(--primary)" />
|
||||
{i18n(I18nKey.atomWhatIsAtom)}
|
||||
</h2>
|
||||
<div class="text-75 space-y-3">
|
||||
<p>
|
||||
{i18n(I18nKey.atomWhatIsAtomDescription)}
|
||||
</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-4">
|
||||
<li>{i18n(I18nKey.atomBenefit1)}</li>
|
||||
<li>{i18n(I18nKey.atomBenefit2)}</li>
|
||||
<li>{i18n(I18nKey.atomBenefit3)}</li>
|
||||
<li>{i18n(I18nKey.atomBenefit4)}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Copy button functionality
|
||||
const init = () => {
|
||||
const copyBtn = document.getElementById('copy-atom-btn');
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', async function() {
|
||||
const url = this.getAttribute('data-url');
|
||||
if (!url) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
const originalText = this.textContent || "Copy Link";
|
||||
this.textContent = "Link Copied";
|
||||
setTimeout(() => {
|
||||
this.textContent = originalText;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err);
|
||||
const originalText = this.textContent || "Copy Link";
|
||||
this.textContent = "Copy Failed";
|
||||
setTimeout(() => {
|
||||
this.textContent = originalText;
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
init();
|
||||
document.addEventListener("astro:after-swap", init);
|
||||
</script>
|
||||
|
||||
</GridLayout>
|
||||
134
src/pages/atom.xml.ts
Normal file
134
src/pages/atom.xml.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { getImage } from "astro:assets";
|
||||
import { parse as htmlParser } from "node-html-parser";
|
||||
import type { APIContext, ImageMetadata } from "astro";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
import { siteConfig, profileConfig } from "@/config";
|
||||
import { getSortedPosts } from "@utils/content";
|
||||
import { getFileDirFromPath, getPostUrl } from "@utils/url";
|
||||
|
||||
|
||||
const markdownParser = new MarkdownIt();
|
||||
|
||||
// get dynamic import of images as a map collection
|
||||
const imagesGlob = import.meta.glob<{ default: ImageMetadata }>(
|
||||
"/src/content/**/*.{jpeg,jpg,png,gif,webp}", // include posts and assets
|
||||
);
|
||||
|
||||
export async function GET(context: APIContext) {
|
||||
if (!context.site) {
|
||||
throw Error("site not set");
|
||||
}
|
||||
|
||||
// Use the same ordering as site listing (pinned first, then by published desc)
|
||||
// 过滤掉加密文章和草稿文章
|
||||
const posts = (await getSortedPosts()).filter((post) => !post.data.encrypted && post.data.draft !== true);
|
||||
|
||||
// 创建Atom feed头部
|
||||
let atomFeed = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>${siteConfig.title}</title>
|
||||
<subtitle>${siteConfig.subtitle || "No description"}</subtitle>
|
||||
<link href="${context.site}" rel="alternate" type="text/html"/>
|
||||
<link href="${new URL("atom.xml", context.site)}" rel="self" type="application/atom+xml"/>
|
||||
<id>${context.site}</id>
|
||||
<updated>${new Date().toISOString()}</updated>
|
||||
<language>${siteConfig.lang}</language>`;
|
||||
|
||||
for (const post of posts) {
|
||||
// convert markdown to html string, ensure post.body is a string
|
||||
const body = markdownParser.render(String(post.body ?? ""));
|
||||
// convert html string to DOM-like structure
|
||||
const html = htmlParser.parse(body);
|
||||
// hold all img tags in variable images
|
||||
const images = html.querySelectorAll("img");
|
||||
|
||||
for (const img of images) {
|
||||
const src = img.getAttribute("src");
|
||||
if (!src) continue;
|
||||
// Handle content-relative images and convert them to built _astro paths
|
||||
if (
|
||||
src.startsWith("./") ||
|
||||
src.startsWith("../") ||
|
||||
(!src.startsWith("http") && !src.startsWith("/"))
|
||||
) {
|
||||
let importPath: string | null = null;
|
||||
// derive base directory from real file path to preserve casing
|
||||
const contentDirRaw = post.filePath
|
||||
? getFileDirFromPath(post.filePath)
|
||||
: "src/content/posts";
|
||||
const contentDir = contentDirRaw.startsWith("src/")
|
||||
? contentDirRaw
|
||||
: `src/${contentDirRaw}`;
|
||||
if (src.startsWith("./")) {
|
||||
// Path relative to the post file directory
|
||||
const prefixRemoved = src.slice(2);
|
||||
importPath = `/${contentDir}/${prefixRemoved}`;
|
||||
} else if (src.startsWith("../")) {
|
||||
// Path like ../assets/images/xxx -> relative to /src/content/
|
||||
const cleaned = src.replace(/^\.\.\//, "");
|
||||
importPath = `/src/content/${cleaned}`;
|
||||
} else {
|
||||
// direct filename (no ./ prefix) - assume it's in the same directory as the post
|
||||
importPath = `/${contentDir}/${src}`;
|
||||
}
|
||||
// import the image module dynamically
|
||||
const imageMod = await imagesGlob[importPath]?.()?.then(
|
||||
(res) => res.default,
|
||||
);
|
||||
if (imageMod) {
|
||||
// optimize the image and get the final src URL
|
||||
const optimizedImg = await getImage({ src: imageMod });
|
||||
img.setAttribute("src", new URL(optimizedImg.src, context.site).href);
|
||||
} else {
|
||||
// log the failed import path
|
||||
console.log(
|
||||
`Failed to load image: ${importPath} for post: ${post.id}`,
|
||||
);
|
||||
}
|
||||
} else if (src.startsWith("/")) {
|
||||
// images starting with `/` are in public dir
|
||||
img.setAttribute("src", new URL(src, context.site).href);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加Atom条目
|
||||
const postUrl = new URL(getPostUrl(post), context.site).href;
|
||||
const content = sanitizeHtml(html.toString(), {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
|
||||
});
|
||||
|
||||
atomFeed += `
|
||||
<entry>
|
||||
<title>${post.data.title}</title>
|
||||
<link href="${postUrl}" rel="alternate" type="text/html"/>
|
||||
<id>${postUrl}</id>
|
||||
<published>${post.data.published.toISOString()}</published>
|
||||
<updated>${post.data.updated?.toISOString() || post.data.published.toISOString()}</updated>
|
||||
<summary>${post.data.description || ""}</summary>
|
||||
<content type="html"><![CDATA[${content}]]></content>
|
||||
<author>
|
||||
<name>${profileConfig.name}</name>
|
||||
</author>`;
|
||||
// 添加分类标签
|
||||
if (post.data.category) {
|
||||
atomFeed += `
|
||||
<category term="${post.data.category}"></category>`;
|
||||
}
|
||||
// 添加标签
|
||||
atomFeed += `
|
||||
</entry>`;
|
||||
}
|
||||
|
||||
// 关闭Atom feed
|
||||
atomFeed += `
|
||||
</feed>`;
|
||||
|
||||
return new Response(atomFeed, {
|
||||
headers: {
|
||||
"Content-Type": "application/atom+xml; charset=utf-8",
|
||||
|
||||
},
|
||||
});
|
||||
}
|
||||
260
src/pages/diary.astro
Normal file
260
src/pages/diary.astro
Normal file
@@ -0,0 +1,260 @@
|
||||
---
|
||||
import { LinkPresets } from "@constants/link-presets";
|
||||
import { LinkPreset } from "@/types/config";
|
||||
import { siteConfig } from "@/config";
|
||||
import { sortedMoments } from "@/utils/diary";
|
||||
import { i18n } from "@i18n/translation";
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
import GridLayout from "@layouts/grid.astro";
|
||||
import BackwardButton from "@components/backwardButton.astro";
|
||||
|
||||
|
||||
const pageTitle = LinkPresets[LinkPreset.Diary].name;
|
||||
const pageDescription = LinkPresets[LinkPreset.Diary].description;
|
||||
|
||||
// 时间格式化函数
|
||||
function formatTime(dateString: string): string {
|
||||
var TG = 8;
|
||||
if (siteConfig.timeZone >= -12 && siteConfig.timeZone <= 12) TG = siteConfig.timeZone;
|
||||
const timeGap = TG;
|
||||
const now = new Date();
|
||||
const date = new Date(dateString);
|
||||
const diffInMinutes = Math.floor(
|
||||
(now.getTime() + timeGap*60*60*1000 - date.getTime()) / (1000 * 60),
|
||||
);
|
||||
if (diffInMinutes < 60) {
|
||||
return `${diffInMinutes}${i18n(I18nKey.diaryMinutesAgo)}`;
|
||||
}
|
||||
if (diffInMinutes < 1440) {
|
||||
// 24小时
|
||||
const hours = Math.floor(diffInMinutes / 60);
|
||||
return `${hours}${i18n(I18nKey.diaryHoursAgo)}`;
|
||||
}
|
||||
const days = Math.floor(diffInMinutes / 1440);
|
||||
return `${days}${i18n(I18nKey.diaryDaysAgo)}`;
|
||||
}
|
||||
---
|
||||
|
||||
<GridLayout title={pageTitle} description={pageDescription}>
|
||||
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
|
||||
<div class="card-base z-10 px-4 py-4 md:px-6 md:py-5 relative w-full">
|
||||
<BackwardButton currentPath={Astro.url.pathname} />
|
||||
<div class="relative max-w-4xl">
|
||||
<!-- 页面头部 -->
|
||||
<div class="moments-header mb-6">
|
||||
<div class="header-content">
|
||||
<div class="header-info">
|
||||
<h1 class="moments-title text-xl md:text-2xl lg:text-3xl font-bold text-90 mb-1">{pageTitle}</h1>
|
||||
<p class="moments-subtitle text-sm md:text-base lg:text-lg text-75">{pageDescription}</p>
|
||||
</div>
|
||||
<div class="header-stats">
|
||||
<div class="stat-item text-center">
|
||||
<span class="stat-number text-lg md:text-xl lg:text-2xl font-bold text-(--primary)">{sortedMoments.length}</span>
|
||||
<span class="stat-label text-xs md:text-sm lg:text-base text-75">{i18n(I18nKey.diaryCount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 短文列表 -->
|
||||
<div class="moments-timeline">
|
||||
<div class="timeline-list space-y-4">
|
||||
{sortedMoments.map(moment => (
|
||||
<div class="moment-item card-base p-4 md:p-6 lg:p-8 hover:shadow-lg transition-all">
|
||||
<div class="moment-content">
|
||||
<p class="moment-text text-sm md:text-base lg:text-lg text-90 leading-relaxed mb-3 md:mb-4">{moment.content}</p>
|
||||
{moment.images && moment.images.length > 0 && (
|
||||
<div class="moment-images grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2 md:gap-3 lg:gap-4 mb-3 md:mb-4">
|
||||
{moment.images.map((image, index) => (
|
||||
<div class="image-item relative rounded-md overflow-hidden aspect-square cursor-pointer hover:scale-105 transition">
|
||||
<img
|
||||
src={image}
|
||||
alt={i18n(I18nKey.diaryImage)}
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<hr class="moment-divider border-t border-(--line-divider) my-3 md:my-4" />
|
||||
<div class="moment-footer flex justify-between items-center">
|
||||
<div class="moment-time flex items-center gap-1.5 text-75 text-xs md:text-sm lg:text-base">
|
||||
<i class="time-icon text-xs md:text-sm">🕐</i>
|
||||
<time datetime={moment.date}>
|
||||
{formatTime(moment.date)}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 底部提示 -->
|
||||
<div class="moments-tips text-center mt-6 md:mt-8 lg:mt-10 text-75 text-xs md:text-sm lg:text-base italic">
|
||||
{i18n(I18nKey.diaryTips)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridLayout>
|
||||
|
||||
<style>
|
||||
.card-base {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--line-divider);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.moments-header {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark, var(--primary)) 100%);
|
||||
color: white;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.moments-title {
|
||||
color: white;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.moments-subtitle {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.image-item img {
|
||||
transition: scale 0.3s ease;
|
||||
}
|
||||
|
||||
.image-item:hover img {
|
||||
scale: 1.05;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* 让内容中的换行符生效(\n -> 换行) */
|
||||
.moment-text {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
|
||||
/* 桌面端 (大于1280px) */
|
||||
@media (min-width: 1280px) {
|
||||
.moments-header {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.moment-item {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.moment-images {
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
max-width: 600px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.moment-text {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* 平板竖屏 (768px - 1279px) - 优化显示 */
|
||||
@media (min-width: 768px) and (max-width: 1279px) {
|
||||
.moments-header {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.moment-item {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.moment-images {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.75rem;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.moment-text {
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.moment-footer {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 手机端 (小于768px) */
|
||||
@media (max-width: 768px) {
|
||||
.moments-header {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.moment-images {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.moment-footer {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 优化小屏幕显示 */
|
||||
@media (max-width: 512px) {
|
||||
.card-base {
|
||||
margin: 0 -0.5rem;
|
||||
}
|
||||
|
||||
.moment-item {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.moments-header {
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
114
src/pages/friends.astro
Normal file
114
src/pages/friends.astro
Normal file
@@ -0,0 +1,114 @@
|
||||
---
|
||||
import { getEntry, render } from "astro:content";
|
||||
|
||||
import { LinkPresets } from "@constants/link-presets";
|
||||
import { LinkPreset } from "@/types/config";
|
||||
import { friendsData } from "@utils/friends";
|
||||
import Markdown from "@components/common/markdown.astro";
|
||||
import GridLayout from "@layouts/grid.astro";
|
||||
import BackwardButton from "@components/backwardButton.astro";
|
||||
|
||||
|
||||
const pageTitle = LinkPresets[LinkPreset.Friends].name;
|
||||
const pageDescription = LinkPresets[LinkPreset.Friends].description;
|
||||
|
||||
const friendsPost = await getEntry("spec", "friends");
|
||||
|
||||
if (!friendsPost) {
|
||||
throw new Error("friends page content not found");
|
||||
}
|
||||
|
||||
const { Content } = await render(friendsPost);
|
||||
const items = friendsData;
|
||||
|
||||
function shuffleArray(array) {
|
||||
const newArray = [...array];
|
||||
for (let i = newArray.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
|
||||
}
|
||||
return newArray;
|
||||
}
|
||||
|
||||
const shuffledItems = shuffleArray(items);
|
||||
---
|
||||
|
||||
<GridLayout title={pageTitle} description={pageDescription}>
|
||||
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
|
||||
<div class="card-base z-10 px-9 py-6 relative w-full ">
|
||||
<BackwardButton currentPath={Astro.url.pathname} />
|
||||
<!-- 标题 -->
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-3">
|
||||
{pageTitle}
|
||||
</h1>
|
||||
{pageDescription && (
|
||||
<p class="text-neutral-600 dark:text-neutral-400">
|
||||
{pageDescription}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
<!-- 链接网格 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 my-4">
|
||||
{shuffledItems.map((item) => (
|
||||
<a href={item.siteurl} target="_blank" rel="noopener noreferrer" class="menu-card group flex flex-row items-center p-4 rounded-xl border transition-all duration-300 gap-4">
|
||||
<div class="icon-wrapper w-20 h-20 shrink-0 flex items-center justify-center rounded-full transition-all duration-300 overflow-hidden bg-zinc-200 dark:bg-zinc-900">
|
||||
<img src={item.imgurl} alt={item.title} class="w-full h-full object-cover transition duration-300 group-hover:scale-110" />
|
||||
</div>
|
||||
<div class="flex flex-col justify-center overflow-hidden">
|
||||
<h2 class="text-lg font-bold text-neutral-900 dark:text-neutral-100 group-hover:text-(--primary) transition-colors duration-300 truncate">
|
||||
{item.title}
|
||||
</h2>
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400 line-clamp-1 mt-1">
|
||||
{item.desc}
|
||||
</p>
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<div class="flex flex-wrap gap-1.5 mt-2">
|
||||
{item.tags.slice(0, 2).map((tag) => (
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400 border border-neutral-200 dark:border-neutral-700">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{item.tags.length > 2 && (
|
||||
<span class="text-[10px] px-2 py-0.5 text-neutral-400">
|
||||
+{item.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<Markdown class="mt-2">
|
||||
<Content />
|
||||
</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
</GridLayout>
|
||||
|
||||
<style>
|
||||
.menu-card {
|
||||
cursor: pointer;
|
||||
background-color: none;
|
||||
border-color: var(--line-divider);
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
background-color: color-mix(in oklch, var(--primary), transparent 85%);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.menu-card:hover {
|
||||
background-color: color-mix(in oklch, var(--primary), transparent 95%);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 20px color-mix(in oklch, var(--primary), transparent 80%);
|
||||
translate: 0 -4px;
|
||||
}
|
||||
|
||||
.menu-card:hover .icon-wrapper {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
box-shadow: 0 0 15px color-mix(in oklch, var(--primary), transparent 50%);
|
||||
}
|
||||
</style>
|
||||
342
src/pages/og/[...slug].png.ts
Normal file
342
src/pages/og/[...slug].png.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import { getCollection } from "astro:content";
|
||||
import type { APIContext, GetStaticPaths } from "astro";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import * as fs from "node:fs";
|
||||
import satori from "satori";
|
||||
import sharp from "sharp";
|
||||
|
||||
import { profileConfig, siteConfig } from "@/config";
|
||||
import { defaultFavicons } from "@constants/icon";
|
||||
|
||||
|
||||
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) => ({
|
||||
params: { slug: post.id },
|
||||
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 fontCache;
|
||||
}
|
||||
|
||||
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 fontCache;
|
||||
}
|
||||
|
||||
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 fontCache;
|
||||
}
|
||||
}
|
||||
|
||||
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 avatarPath = `./public${profileConfig.avatar}`;
|
||||
const avatarBuffer = fs.readFileSync(avatarPath);
|
||||
const avatarBase64 = `data:image/png;base64,${avatarBuffer.toString("base64")}`;
|
||||
|
||||
let iconPath = `./public${defaultFavicons[0].src}`;
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
358
src/pages/posts/[...slug].astro
Normal file
358
src/pages/posts/[...slug].astro
Normal file
@@ -0,0 +1,358 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
import { render } from "astro:content";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
import { siteConfig, profileConfig, postConfig } from "@/config";
|
||||
import { getSortedPosts } from "@utils/content";
|
||||
import { formatDateToYYYYMMDD } from "@utils/date";
|
||||
import { getFileDirFromPath, getPostUrl, getPostUrlBySlug, removeFileExtension} from "@utils/url";
|
||||
import { i18n } from "@i18n/translation";
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
import License from "@components/post/license.astro";
|
||||
import Markdown from "@components/common/markdown.astro";
|
||||
import ImageWrapper from "@components/common/imageWrapper.astro";
|
||||
import Comment from "@components/post/comment.astro";
|
||||
import PasswordProtection from "@components/common/passwordProtection.astro";
|
||||
import PostMetadata from "@components/post/postMeta.astro";
|
||||
import GridLayout from "@layouts/grid.astro";
|
||||
|
||||
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const blogEntries = await getSortedPosts();
|
||||
const paths: {
|
||||
params: { slug: string };
|
||||
props: { entry: CollectionEntry<"posts"> };
|
||||
}[] = [];
|
||||
|
||||
for (const entry of blogEntries) {
|
||||
// 将 id 转换为 slug(移除扩展名)以匹配路由参数
|
||||
const slug = removeFileExtension(entry.id);
|
||||
// 为每篇文章创建默认的 slug 路径
|
||||
paths.push({
|
||||
params: { slug },
|
||||
props: { entry },
|
||||
});
|
||||
// 如果文章有自定义固定链接,也创建对应的路径
|
||||
if (entry.data.routeName) {
|
||||
// 移除开头的斜杠和结尾的斜杠
|
||||
// 同时移除可能的 "posts/" 前缀,避免重复
|
||||
let routeName = entry.data.routeName
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/\/+$/, "");
|
||||
if (routeName.startsWith("posts/")) {
|
||||
routeName = routeName.replace(/^posts\//, "");
|
||||
}
|
||||
paths.push({
|
||||
params: { slug: routeName },
|
||||
props: { entry },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
|
||||
const { entry } = Astro.props;
|
||||
const { Content, headings } = await render(entry);
|
||||
|
||||
const { remarkPluginFrontmatter } = await render(entry);
|
||||
|
||||
// 处理加密逻辑
|
||||
let isEncrypted = !!(entry.data.encrypted && entry.data.password);
|
||||
|
||||
dayjs.extend(utc);
|
||||
const lastModified = dayjs(entry.data.updated || entry.data.published)
|
||||
.utc()
|
||||
.format("YYYY-MM-DDTHH:mm:ss");
|
||||
|
||||
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
|
||||
};
|
||||
---
|
||||
|
||||
<GridLayout
|
||||
banner={entry.data.cover}
|
||||
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-(--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-up"
|
||||
>
|
||||
<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-up">
|
||||
<div
|
||||
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-(--primary)
|
||||
before:absolute before:top-3 before:-left-4.5"
|
||||
>
|
||||
{entry.data.title}
|
||||
</div>
|
||||
</div>
|
||||
<!-- metadata -->
|
||||
<div class="onload-animation-up">
|
||||
<PostMetadata
|
||||
className="mb-5"
|
||||
published={entry.data.published}
|
||||
updated={entry.data.updated}
|
||||
tags={entry.data.tags}
|
||||
category={entry.data.category}
|
||||
postUrl={getPostUrl(entry)}
|
||||
/>
|
||||
{
|
||||
(!entry.data.cover || (entry.data.cover && !postConfig.showCoverInContent)) && (
|
||||
<div class="mt-4 border-(--line-divider) border-dashed border-b mb-5" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<!-- always show cover as long as it has one -->
|
||||
{
|
||||
entry.data.cover && postConfig.showCoverInContent && (
|
||||
<>
|
||||
<div class="mt-4" />
|
||||
<ImageWrapper
|
||||
id="post-cover"
|
||||
src={entry.data.cover}
|
||||
basePath={getFileDirFromPath(entry.filePath || '')}
|
||||
class="mb-8 rounded-xl banner-container onload-animation-up"
|
||||
loading="lazy"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<PasswordProtection
|
||||
isEncrypted={isEncrypted}
|
||||
password={entry.data.password}
|
||||
>
|
||||
<Markdown class="mb-6 markdown-content onload-animation-up">
|
||||
<Content />
|
||||
</Markdown>
|
||||
{
|
||||
postConfig.license.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}
|
||||
postUrl={getPostUrl(entry)}
|
||||
class="mb-6 rounded-xl license-container onload-animation-up"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</PasswordProtection>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 评论 -->
|
||||
<Comment post={entry} />
|
||||
{postConfig.showLastModified && (
|
||||
<>
|
||||
<div
|
||||
id="last-modified"
|
||||
data-last-modified={lastModified}
|
||||
data-prefix={i18n(I18nKey.lastModifiedPrefix)}
|
||||
data-year={i18n(I18nKey.year)}
|
||||
data-month={i18n(I18nKey.month)}
|
||||
data-day={i18n(I18nKey.day)}
|
||||
data-hour={i18n(I18nKey.hour)}
|
||||
data-minute={i18n(I18nKey.minute)}
|
||||
data-second={i18n(I18nKey.second)}
|
||||
style="display: none;"
|
||||
></div>
|
||||
|
||||
<div class="card-base p-6 mb-4">
|
||||
<script is:inline>
|
||||
function runtime() {
|
||||
const lastModifiedElement = document.getElementById('last-modified');
|
||||
if (!lastModifiedElement) return;
|
||||
const startDate = new Date(lastModifiedElement.dataset.lastModified);
|
||||
const currentDate = new Date();
|
||||
const diff = currentDate - startDate;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
const years = Math.floor(days / 365);
|
||||
const months = Math.floor((days % 365) / 30);
|
||||
const remainingDays = days % 30;
|
||||
const prefix = lastModifiedElement.dataset.prefix;
|
||||
const yearKey = lastModifiedElement.dataset.year;
|
||||
const monthKey = lastModifiedElement.dataset.month;
|
||||
const dayKey = lastModifiedElement.dataset.day;
|
||||
const hourKey = lastModifiedElement.dataset.hour;
|
||||
const minuteKey = lastModifiedElement.dataset.minute;
|
||||
const secondKey = lastModifiedElement.dataset.second;
|
||||
let runtimeString = prefix + ' ';
|
||||
if (years > 0) {
|
||||
runtimeString += `${years} ${yearKey} `;
|
||||
}
|
||||
if (months > 0) {
|
||||
runtimeString += `${months} ${monthKey} `;
|
||||
}
|
||||
if (remainingDays > 0) {
|
||||
runtimeString += `${remainingDays} ${dayKey} `;
|
||||
}
|
||||
runtimeString += `${hours} ${hourKey} `;
|
||||
if (minutes < 10) {
|
||||
runtimeString += `0${minutes} ${minuteKey} `;
|
||||
} else {
|
||||
runtimeString += `${minutes} ${minuteKey} `;
|
||||
}
|
||||
if (secs < 10) {
|
||||
runtimeString += `0${secs} ${secondKey}`;
|
||||
} else {
|
||||
runtimeString += `${secs} ${secondKey}`;
|
||||
}
|
||||
document.getElementById("modifiedtime").innerHTML = runtimeString;
|
||||
}
|
||||
setInterval(runtime, 1000);
|
||||
</script>
|
||||
<div class="card-base p-1 mb-0.1">
|
||||
<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-(--primary) transition-transform group-hover:translate-x-0.5 bg-(--enter-btn-bg) p-2 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.1">
|
||||
<div id="modifiedtime" class="text-[1.0rem] leading-tight text-black/75 dark:text-white/75"></div>
|
||||
<p class="text-[0.8rem] leading-tight text-black/75 dark:text-white/75">
|
||||
{i18n(I18nKey.lastModifiedOutdated)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<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-15 max-w-full px-4 flex items-center justify-start! gap-4">
|
||||
<Icon
|
||||
name="material-symbols:chevron-left-rounded"
|
||||
class="text-[2rem] text-(--primary)"
|
||||
/>
|
||||
<div class="overflow-hidden transition text-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-15 max-w-full px-4 flex items-center justify-end! gap-4">
|
||||
<div class="overflow-hidden transition text-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-(--primary)"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
</GridLayout>
|
||||
146
src/pages/projects.astro
Normal file
146
src/pages/projects.astro
Normal file
@@ -0,0 +1,146 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
|
||||
import { LinkPresets } from "@constants/link-presets";
|
||||
import { LinkPreset } from "@/types/config";
|
||||
import {
|
||||
projectsData,
|
||||
getProjectStats,
|
||||
getProjectsByCategory,
|
||||
getFeaturedProjects,
|
||||
getAllTechStack,
|
||||
} from "@utils/projects";
|
||||
import { UNCATEGORIZED } from "@constants/constants";
|
||||
import { i18n } from "@i18n/translation";
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
import ProjectCard from "@components/data/projectCard.astro";
|
||||
import GridLayout from "@layouts/grid.astro";
|
||||
import BackwardButton from "@components/backwardButton.astro";
|
||||
|
||||
|
||||
const title = LinkPresets[LinkPreset.Projects].name;
|
||||
const subtitle = LinkPresets[LinkPreset.Projects].description;
|
||||
|
||||
// 获取项目统计信息
|
||||
const stats = getProjectStats();
|
||||
const featuredProjects = getFeaturedProjects();
|
||||
const allTechStack = getAllTechStack();
|
||||
|
||||
// 获取所有分类
|
||||
const categories = [
|
||||
...new Set(projectsData.map((project) => project.category)),
|
||||
];
|
||||
|
||||
// 按分类获取项目
|
||||
const projectsByCategory = categories.reduce(
|
||||
(acc, category) => {
|
||||
acc[category] = getProjectsByCategory(category);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof projectsData>,
|
||||
);
|
||||
|
||||
// 获取分类文本的国际化翻译
|
||||
const getCategoryText = (category: string) => {
|
||||
switch (category) {
|
||||
case "web":
|
||||
return i18n(I18nKey.projectsWeb);
|
||||
case "mobile":
|
||||
return i18n(I18nKey.projectsMobile);
|
||||
case "desktop":
|
||||
return i18n(I18nKey.projectsDesktop);
|
||||
case "other":
|
||||
return i18n(I18nKey.projectsOther);
|
||||
case UNCATEGORIZED:
|
||||
return i18n(I18nKey.uncategorized);
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
};
|
||||
---
|
||||
|
||||
<GridLayout title={title} description={subtitle}>
|
||||
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
|
||||
<div class="card-base z-10 px-9 py-6 relative w-full">
|
||||
<BackwardButton currentPath={Astro.url.pathname} />
|
||||
<!-- 页面标题 -->
|
||||
<div class="flex flex-col items-start justify-center mb-8">
|
||||
<h1 class="text-4xl font-bold text-black/90 dark:text-white/90 mb-2">
|
||||
{i18n(I18nKey.projects)}
|
||||
</h1>
|
||||
<p class="text-lg text-black/60 dark:text-white/60">
|
||||
{i18n(I18nKey.projectsSubtitle)}
|
||||
</p>
|
||||
</div>
|
||||
<!-- 统计信息 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-linear-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.total}</div>
|
||||
<div class="text-sm text-blue-600/70 dark:text-blue-400/70">{i18n(I18nKey.projectsTotal)}</div>
|
||||
</div>
|
||||
<div class="bg-linear-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-green-600 dark:text-green-400">{stats.byStatus.completed}</div>
|
||||
<div class="text-sm text-green-600/70 dark:text-green-400/70">{i18n(I18nKey.projectsCompleted)}</div>
|
||||
</div>
|
||||
<div class="bg-linear-to-br from-yellow-50 to-yellow-100 dark:from-yellow-900/20 dark:to-yellow-800/20 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-yellow-600 dark:text-yellow-400">{stats.byStatus.inProgress}</div>
|
||||
<div class="text-sm text-yellow-600/70 dark:text-yellow-400/70">{i18n(I18nKey.projectsInProgress)}</div>
|
||||
</div>
|
||||
<div class="bg-linear-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-purple-600 dark:text-purple-400">{allTechStack.length}</div>
|
||||
<div class="text-sm text-purple-600/70 dark:text-purple-400/70">{i18n(I18nKey.projectsTechStack)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 特色项目 -->
|
||||
{featuredProjects.length > 0 && (
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-4">
|
||||
{i18n(I18nKey.projectsFeatured)}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{featuredProjects.map((project) => (
|
||||
<ProjectCard project={project} size="large" showImage={true} maxTechStack={4} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<!-- 按分类展示项目 -->
|
||||
<div class="space-y-8">
|
||||
{categories.map((category) => {
|
||||
const categoryProjects = projectsByCategory[category];
|
||||
if (categoryProjects.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-4">
|
||||
{getCategoryText(category)}
|
||||
<span class="text-lg font-normal text-black/60 dark:text-white/60 ml-2">
|
||||
({categoryProjects.length})
|
||||
</span>
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{categoryProjects.map((project) => (
|
||||
<ProjectCard project={project} size="medium" showImage={true} maxTechStack={3} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<!-- 技术栈统计 -->
|
||||
<div class="mt-12 pt-8 border-t border-black/10 dark:border-white/10">
|
||||
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-4">
|
||||
{i18n(I18nKey.projectsTechStack)}
|
||||
</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{allTechStack.map((tech) => (
|
||||
<span class="px-3 py-1 text-sm bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 rounded-full">
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridLayout>
|
||||
17
src/pages/robots.txt.ts
Normal file
17
src/pages/robots.txt.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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",
|
||||
},
|
||||
});
|
||||
};
|
||||
137
src/pages/rss.astro
Normal file
137
src/pages/rss.astro
Normal file
@@ -0,0 +1,137 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
import { getSortedPosts } from "@utils/content";
|
||||
import { formatDateToYYYYMMDD } from "@utils/date";
|
||||
import { i18n } from "@i18n/translation";
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
import GridLayout from "@layouts/grid.astro";
|
||||
|
||||
|
||||
const posts = (await getSortedPosts()).filter((post) => !post.data.encrypted);
|
||||
const recentPosts = posts.slice(0, 6);
|
||||
---
|
||||
|
||||
<GridLayout title={i18n(I18nKey.rss)} description={i18n(I18nKey.rssDescription)}>
|
||||
|
||||
<div class="onload-animation-up">
|
||||
<!-- RSS 标题和介绍 -->
|
||||
<div class="card-base rounded-(--radius-large) p-8 mb-6">
|
||||
<div class="text-center">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-(--primary) rounded-2xl mb-4">
|
||||
<Icon name="material-symbols:rss-feed" class="text-white text-3xl" />
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-(--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-(--radius-large) p-6 mb-6">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-(--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 gap-3">
|
||||
<code class="bg-(--card-bg) px-3 py-2 rounded-lg text-sm font-mono text-75 border border-(--line-divider) break-all">
|
||||
{Astro.site}rss.xml
|
||||
</code>
|
||||
<button
|
||||
id="copy-rss-btn"
|
||||
class="px-4 py-2 bg-(--primary) text-white rounded-lg hover:opacity-80 transition-all duration-300 font-medium text-sm whitespace-nowrap"
|
||||
data-url={`${Astro.site}rss.xml`}
|
||||
>
|
||||
{i18n(I18nKey.rssCopyLink)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 最新文章预览 -->
|
||||
<div class="card-base rounded-(--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-(--primary)" />
|
||||
{i18n(I18nKey.rssLatestPosts)}
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
{recentPosts.map((post) => (
|
||||
<article class="bg-(--card-bg) rounded-xl p-4 border border-(--line-divider) hover:border-(--primary) transition-all duration-300">
|
||||
<h3 class="text-lg font-semibold text-90 mb-2 hover:text-(--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-(--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-(--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>
|
||||
// Copy button functionality
|
||||
const init = () => {
|
||||
const copyBtn = document.getElementById('copy-rss-btn');
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', async function() {
|
||||
const url = this.getAttribute('data-url');
|
||||
if (!url) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
const originalText = this.textContent || "Copy Link";
|
||||
this.textContent = "Link Copied";
|
||||
setTimeout(() => {
|
||||
this.textContent = originalText;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err);
|
||||
const originalText = this.textContent || "Copy Link";
|
||||
this.textContent = "Copy Failed";
|
||||
setTimeout(() => {
|
||||
this.textContent = originalText;
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
init();
|
||||
document.addEventListener("astro:after-swap", init);
|
||||
</script>
|
||||
|
||||
</GridLayout>
|
||||
105
src/pages/rss.xml.ts
Normal file
105
src/pages/rss.xml.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { getImage } from "astro:assets";
|
||||
import { parse as htmlParser } from "node-html-parser";
|
||||
import type { APIContext, ImageMetadata } from "astro";
|
||||
import type { RSSFeedItem } from "@astrojs/rss";
|
||||
import rss from "@astrojs/rss";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
import { siteConfig } from "@/config";
|
||||
import { getSortedPosts } from "@utils/content";
|
||||
import { getFileDirFromPath, getPostUrl } from "@utils/url";
|
||||
|
||||
|
||||
const markdownParser = new MarkdownIt();
|
||||
|
||||
// get dynamic import of images as a map collection
|
||||
const imagesGlob = import.meta.glob<{ default: ImageMetadata }>(
|
||||
"/src/content/**/*.{jpeg,jpg,png,gif,webp}", // include posts and assets
|
||||
);
|
||||
|
||||
export async function GET(context: APIContext) {
|
||||
if (!context.site) {
|
||||
throw Error("site not set");
|
||||
}
|
||||
|
||||
// Use the same ordering as site listing (pinned first, then by published desc)
|
||||
const posts = (await getSortedPosts()).filter((post) => !post.data.encrypted);
|
||||
const feed: RSSFeedItem[] = [];
|
||||
|
||||
for (const post of posts) {
|
||||
// convert markdown to html string, ensure post.body is a string
|
||||
const body = markdownParser.render(String(post.body ?? ""));
|
||||
// convert html string to DOM-like structure
|
||||
const html = htmlParser.parse(body);
|
||||
// hold all img tags in variable images
|
||||
const images = html.querySelectorAll("img");
|
||||
// process each image tag to correct src paths
|
||||
for (const img of images) {
|
||||
const src = img.getAttribute("src");
|
||||
if (!src) continue;
|
||||
// Handle content-relative images and convert them to built _astro paths
|
||||
if (
|
||||
src.startsWith("./") ||
|
||||
src.startsWith("../") ||
|
||||
(!src.startsWith("http") && !src.startsWith("/"))
|
||||
) {
|
||||
let importPath: string | null = null;
|
||||
// derive base directory from real file path to preserve casing
|
||||
const contentDirRaw = post.filePath
|
||||
? getFileDirFromPath(post.filePath)
|
||||
: "src/content/posts";
|
||||
const contentDir = contentDirRaw.startsWith("src/")
|
||||
? contentDirRaw
|
||||
: `src/${contentDirRaw}`;
|
||||
if (src.startsWith("./")) {
|
||||
// Path relative to the post file directory
|
||||
const prefixRemoved = src.slice(2);
|
||||
importPath = `/${contentDir}/${prefixRemoved}`;
|
||||
} else if (src.startsWith("../")) {
|
||||
// Path like ../assets/images/xxx -> relative to /src/content/
|
||||
const cleaned = src.replace(/^\.\.\//, "");
|
||||
importPath = `/src/content/${cleaned}`;
|
||||
} else {
|
||||
// direct filename (no ./ prefix) - assume it's in the same directory as the post
|
||||
importPath = `/${contentDir}/${src}`;
|
||||
}
|
||||
// import the image module dynamically
|
||||
const imageMod = await imagesGlob[importPath]?.()?.then(
|
||||
(res) => res.default,
|
||||
);
|
||||
if (imageMod) {
|
||||
const optimizedImg = await getImage({ src: imageMod });
|
||||
img.setAttribute("src", new URL(optimizedImg.src, context.site).href);
|
||||
} else {
|
||||
// log the failed import path
|
||||
console.log(
|
||||
`Failed to load image: ${importPath} for post: ${post.id}`,
|
||||
);
|
||||
}
|
||||
} else if (src.startsWith("/")) {
|
||||
// images starting with `/` are in public dir
|
||||
img.setAttribute("src", new URL(src, context.site).href);
|
||||
}
|
||||
}
|
||||
|
||||
feed.push({
|
||||
title: post.data.title,
|
||||
description: post.data.description,
|
||||
pubDate: post.data.published,
|
||||
link: getPostUrl(post),
|
||||
// sanitize the new html string with corrected image paths
|
||||
content: sanitizeHtml(html.toString(), {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return rss({
|
||||
title: siteConfig.title,
|
||||
description: siteConfig.subtitle || "No description",
|
||||
site: context.site,
|
||||
items: feed,
|
||||
customData: `<language>${siteConfig.lang}</language>`,
|
||||
});
|
||||
}
|
||||
253
src/pages/skills.astro
Normal file
253
src/pages/skills.astro
Normal file
@@ -0,0 +1,253 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
|
||||
import { LinkPresets } from "@constants/link-presets";
|
||||
import { LinkPreset } from "@/types/config";
|
||||
import { UNCATEGORIZED } from "@constants/constants";
|
||||
import {
|
||||
getAdvancedSkills,
|
||||
getSkillStats,
|
||||
getSkillsByCategory,
|
||||
getTotalExperience,
|
||||
skillsData,
|
||||
} from "@utils/skills";
|
||||
import { i18n } from "@i18n/translation";
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
import IconifyLoader from "@components/common/iconifyLoader.astro";
|
||||
import SkillCard from "@components/data/skillCard.astro";
|
||||
import GridLayout from "@layouts/grid.astro";
|
||||
import BackwardButton from "@components/backwardButton.astro";
|
||||
|
||||
|
||||
const title = LinkPresets[LinkPreset.Skills].name;
|
||||
const subtitle = LinkPresets[LinkPreset.Skills].description;
|
||||
|
||||
// 获取技能统计信息
|
||||
const stats = getSkillStats();
|
||||
const advancedSkills = getAdvancedSkills();
|
||||
const totalExperience = getTotalExperience();
|
||||
|
||||
// 获取所有分类
|
||||
const categories = [...new Set(skillsData.map((skill) => skill.category))];
|
||||
|
||||
// 按分类获取技能
|
||||
const skillsByCategory = categories.reduce(
|
||||
(acc, category) => {
|
||||
acc[category] = getSkillsByCategory(category);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof skillsData>,
|
||||
);
|
||||
|
||||
// 获取分类的翻译文本
|
||||
const getCategoryText = (category: string) => {
|
||||
switch (category) {
|
||||
case "ai":
|
||||
return i18n(I18nKey.skillsAI);
|
||||
case "server":
|
||||
return i18n(I18nKey.skillsBackend);
|
||||
case "client":
|
||||
return i18n(I18nKey.skillsClient);
|
||||
case "web":
|
||||
return i18n(I18nKey.skillsFrontend);
|
||||
case "database":
|
||||
return i18n(I18nKey.skillsDatabase);
|
||||
case "engines":
|
||||
return i18n(I18nKey.skillsEngines);
|
||||
case "tools":
|
||||
return i18n(I18nKey.skillsTools);
|
||||
case "others":
|
||||
return i18n(I18nKey.skillsOthers);
|
||||
case UNCATEGORIZED:
|
||||
return i18n(I18nKey.uncategorized);
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
};
|
||||
|
||||
// 收集所有技能图标用于预加载
|
||||
const allIcons = skillsData.map((skill) => skill.icon).filter(Boolean);
|
||||
---
|
||||
|
||||
<GridLayout title={title} description={subtitle}>
|
||||
<!-- 图标加载器,预加载所有技能图标 -->
|
||||
<IconifyLoader preloadIcons={allIcons} />
|
||||
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
|
||||
<div class="card-base z-10 px-9 py-6 relative w-full">
|
||||
<BackwardButton currentPath={Astro.url.pathname} />
|
||||
<!-- 页面标题 -->
|
||||
<div class="flex flex-col items-start justify-center mb-8">
|
||||
<h1 class="text-4xl font-bold text-black/90 dark:text-white/90 mb-2">
|
||||
{i18n(I18nKey.skills)}
|
||||
</h1>
|
||||
<p class="text-lg text-black/60 dark:text-white/60">
|
||||
{i18n(I18nKey.skillsSubtitle)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-linear-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.total}</div>
|
||||
<div class="text-sm text-blue-600/70 dark:text-blue-400/70">{i18n(I18nKey.skillsTotal)}</div>
|
||||
</div>
|
||||
<div class="bg-linear-to-br from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/20 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-red-600 dark:text-red-400">{stats.byLevel.expert}</div>
|
||||
<div class="text-sm text-red-600/70 dark:text-red-400/70">{i18n(I18nKey.skillsExpert)}</div>
|
||||
</div>
|
||||
<div class="bg-linear-to-br from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-800/20 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-orange-600 dark:text-orange-400">{stats.byLevel.advanced}</div>
|
||||
<div class="text-sm text-orange-600/70 dark:text-orange-400/70">{i18n(I18nKey.skillsAdvanced)}</div>
|
||||
</div>
|
||||
<div class="bg-linear-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{totalExperience.years}{totalExperience.months > 0 ? `.${totalExperience.months}` : ''}
|
||||
</div>
|
||||
<div class="text-sm text-purple-600/70 dark:text-purple-400/70">{i18n(I18nKey.skillExperience)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 专业技能 -->
|
||||
{advancedSkills.length > 0 && (
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-4">
|
||||
{i18n(I18nKey.skillsAdvancedTitle)}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{advancedSkills.map((skill) => (
|
||||
<SkillCard skill={skill} size="large" showProgress={true} showIcon={true} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- 按分类展示技能 -->
|
||||
<div class="space-y-8">
|
||||
{categories.map((category) => {
|
||||
const categorySkills = skillsByCategory[category];
|
||||
if (categorySkills.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-4">
|
||||
{getCategoryText(category)}
|
||||
<span class="text-lg font-normal text-black/60 dark:text-white/60 ml-2">
|
||||
({categorySkills.length})
|
||||
</span>
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{categorySkills.map((skill) => (
|
||||
<SkillCard skill={skill} size="medium" showProgress={true} showIcon={true} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<!-- 技能分布图表 -->
|
||||
<div class="mt-12 pt-8 border-t border-black/10 dark:border-white/10">
|
||||
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-6">
|
||||
{i18n(I18nKey.skillsDistribution)}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<!-- 按等级分布 -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-black/80 dark:text-white/80 mb-4">
|
||||
{i18n(I18nKey.skillsByLevel)}
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
{Object.entries(stats.byLevel).map(([level, count]) => {
|
||||
const percentage = Math.round((count / stats.total) * 100);
|
||||
return (
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-20 text-sm text-black/70 dark:text-white/70">
|
||||
{i18n(level === 'expert' ? I18nKey.skillsExpert :
|
||||
level === 'advanced' ? I18nKey.skillsAdvanced :
|
||||
level === 'intermediate' ? I18nKey.skillsIntermediate :
|
||||
I18nKey.skillsBeginner)}
|
||||
</div>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class={`h-2 rounded-full transition-all duration-600 ${
|
||||
level === 'expert' ? 'bg-red-500' :
|
||||
level === 'advanced' ? 'bg-orange-500' :
|
||||
level === 'intermediate' ? 'bg-yellow-500' :
|
||||
'bg-green-500'
|
||||
}`}
|
||||
style={`width: ${percentage}%`}
|
||||
></div>
|
||||
</div>
|
||||
<div class="w-12 text-sm text-black/70 dark:text-white/70 text-right">
|
||||
{count}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按分类分布 -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-black/80 dark:text-white/80 mb-4">
|
||||
{i18n(I18nKey.skillsByCategory)}
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
{Object.entries(stats.byCategory).map(([category, count]) => {
|
||||
const percentage = Math.round((count / stats.total) * 100);
|
||||
return (
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-20 text-sm text-black/70 dark:text-white/70 truncate">
|
||||
{getCategoryText(category)}
|
||||
</div>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full bg-blue-500 transition-all duration-500"
|
||||
style={`width: ${percentage}%`}
|
||||
></div>
|
||||
</div>
|
||||
<div class="w-12 text-sm text-black/70 dark:text-white/70 text-right">
|
||||
{count}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridLayout>
|
||||
|
||||
<script>
|
||||
// 确保图标库已加载,并处理页面导航
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 如果图标加载器存在,确保图标已加载
|
||||
if (window.__iconifyLoader) {
|
||||
window.__iconifyLoader.load().then(() => {
|
||||
// 图标加载完成后,触发技能卡片的重新渲染
|
||||
const skillCards = document.querySelectorAll('.skill-card');
|
||||
skillCards.forEach(card => {
|
||||
card.dispatchEvent(new CustomEvent('iconify-ready'));
|
||||
});
|
||||
}).catch(error => {
|
||||
console.error('Failed to load icons on skills page:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 处理页面导航时的图标重新加载
|
||||
if (typeof window !== 'undefined') {
|
||||
// 监听页面显示事件(包括前进/后退导航)
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
if (event.persisted && window.__iconifyLoader) {
|
||||
// 页面从缓存恢复,重新检查图标状态
|
||||
setTimeout(() => {
|
||||
window.__iconifyLoader.load().catch(console.error);
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
219
src/pages/timeline.astro
Normal file
219
src/pages/timeline.astro
Normal file
@@ -0,0 +1,219 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
|
||||
import { LinkPresets } from "@constants/link-presets";
|
||||
import { LinkPreset } from "@/types/config";
|
||||
import {
|
||||
timelineData,
|
||||
getTimelineStats,
|
||||
getTimelineByType,
|
||||
getCurrentItems,
|
||||
getTotalWorkExperience,
|
||||
} from "@utils/timeline";
|
||||
import { i18n } from "@i18n/translation";
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
import IconifyLoader from "@components/common/iconifyLoader.astro";
|
||||
import TimelineItem from "@components/data/timelineItem.astro";
|
||||
import GridLayout from "@layouts/grid.astro";
|
||||
import BackwardButton from "@components/backwardButton.astro";
|
||||
|
||||
|
||||
const title = LinkPresets[LinkPreset.Timeline].name;
|
||||
const subtitle = LinkPresets[LinkPreset.Timeline].description;
|
||||
|
||||
// 收集所有时间线图标用于预加载
|
||||
const allIcons = timelineData
|
||||
.map((item) => item.icon || getTypeIcon(item.type))
|
||||
.filter(Boolean);
|
||||
|
||||
// 获取时间线统计信息
|
||||
const stats = getTimelineStats();
|
||||
const currentItems = getCurrentItems();
|
||||
const workExperience = getTotalWorkExperience();
|
||||
|
||||
// 获取所有时间线项目(按时间倒序)
|
||||
const allTimelineItems = getTimelineByType();
|
||||
|
||||
// 类型图标映射
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "education":
|
||||
return "material-symbols:school";
|
||||
case "work":
|
||||
return "material-symbols:work";
|
||||
case "project":
|
||||
return "material-symbols:code";
|
||||
case "achievement":
|
||||
return "material-symbols:emoji-events";
|
||||
default:
|
||||
return "material-symbols:event";
|
||||
}
|
||||
};
|
||||
---
|
||||
|
||||
<GridLayout title={title} description={subtitle}>
|
||||
<!-- 图标加载器,预加载所有时间线图标 -->
|
||||
<IconifyLoader preloadIcons={allIcons} />
|
||||
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
|
||||
<div class="card-base z-10 px-9 py-6 relative w-full">
|
||||
<BackwardButton currentPath={Astro.url.pathname} />
|
||||
<!-- 页面标题 -->
|
||||
<div class="flex flex-col items-start justify-center mb-8">
|
||||
<h1 class="text-4xl font-bold text-black/90 dark:text-white/90 mb-2">
|
||||
{i18n(I18nKey.timeline)}
|
||||
</h1>
|
||||
<p class="text-lg text-black/60 dark:text-white/60">
|
||||
{i18n(I18nKey.timelineSubtitle)}
|
||||
</p>
|
||||
</div>
|
||||
<!-- 统计信息 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-linear-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.total}</div>
|
||||
<div class="text-sm text-blue-600/70 dark:text-blue-400/70">{i18n(I18nKey.timelineTotal)}</div>
|
||||
</div>
|
||||
<div class="bg-linear-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-green-600 dark:text-green-400">{stats.byType.work}</div>
|
||||
<div class="text-sm text-green-600/70 dark:text-green-400/70">{i18n(I18nKey.timelineWork)}</div>
|
||||
</div>
|
||||
<div class="bg-linear-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-purple-600 dark:text-purple-400">{stats.byType.project}</div>
|
||||
<div class="text-sm text-purple-600/70 dark:text-purple-400/70">{i18n(I18nKey.timelineProjects)}</div>
|
||||
</div>
|
||||
<div class="bg-linear-to-br from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-800/20 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||||
{workExperience.years}+
|
||||
</div>
|
||||
<div class="text-sm text-orange-600/70 dark:text-orange-400/70">{i18n(I18nKey.timelineExperience)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 当前进行中的项目 -->
|
||||
{currentItems.length > 0 && (
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-4">
|
||||
{i18n(I18nKey.timelineCurrent)}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{currentItems.map((item) => (
|
||||
<TimelineItem item={item} layout="card" size="medium" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<!-- 时间线 -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-6">
|
||||
{i18n(I18nKey.timelineHistory)}
|
||||
</h2>
|
||||
<div class="relative">
|
||||
<!-- 时间线主轴 -->
|
||||
<div class="absolute left-6 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="space-y-8">
|
||||
{allTimelineItems.map((item, index) => (
|
||||
<TimelineItem item={item} layout="timeline" showTimeline={true} size="medium" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 类型统计 -->
|
||||
<div class="mt-12 pt-8 border-t border-black/10 dark:border-white/10">
|
||||
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-6">
|
||||
{i18n(I18nKey.timelineStatistics)}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<!-- 按类型分布 -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-black/80 dark:text-white/80 mb-4">
|
||||
{i18n(I18nKey.timelineByType)}
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
{Object.entries(stats.byType).map(([type, count]) => {
|
||||
const percentage = Math.round((count / stats.total) * 100);
|
||||
return (
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-20 text-sm text-black/70 dark:text-white/70">
|
||||
{i18n(type === 'education' ? I18nKey.timelineEducation :
|
||||
type === 'work' ? I18nKey.timelineWork :
|
||||
type === 'project' ? I18nKey.timelineProject :
|
||||
I18nKey.timelineAchievement)}
|
||||
</div>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class={`h-2 rounded-full transition-all duration-600 ${
|
||||
type === 'education' ? 'bg-blue-500' :
|
||||
type === 'work' ? 'bg-green-500' :
|
||||
type === 'project' ? 'bg-purple-500' :
|
||||
'bg-orange-500'
|
||||
}`}
|
||||
style={`width: ${percentage}%`}
|
||||
></div>
|
||||
</div>
|
||||
<div class="w-12 text-sm text-black/70 dark:text-white/70 text-right">
|
||||
{count}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 工作经验详情 -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-black/80 dark:text-white/80 mb-4">
|
||||
{i18n(I18nKey.timelineWorkExperience)}
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-black/70 dark:text-white/70">{i18n(I18nKey.timelineTotalExperience)}</span>
|
||||
<span class="font-semibold text-black/90 dark:text-white/90">
|
||||
{workExperience.years} {i18n(I18nKey.timelineYears)} {workExperience.months} {i18n(I18nKey.timelineMonths)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-black/70 dark:text-white/70">{i18n(I18nKey.timelineWorkPositions)}</span>
|
||||
<span class="font-semibold text-black/90 dark:text-white/90">{stats.byType.work}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-black/70 dark:text-white/70">{i18n(I18nKey.timelineCurrentRole)}</span>
|
||||
<span class="font-semibold text-black/90 dark:text-white/90">
|
||||
{currentItems.filter(item => item.type === 'work').length > 0 ? i18n(I18nKey.timelineEmployed) : i18n(I18nKey.timelineAvailable)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridLayout>
|
||||
|
||||
<script>
|
||||
// 确保图标库已加载,并处理页面导航
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 如果图标加载器存在,确保图标已加载
|
||||
if (window.__iconifyLoader) {
|
||||
window.__iconifyLoader.load().then(() => {
|
||||
// 图标加载完成后,触发时间线项目的重新渲染
|
||||
const timelineItems = document.querySelectorAll('.timeline-item');
|
||||
timelineItems.forEach(item => {
|
||||
item.dispatchEvent(new CustomEvent('iconify-ready'));
|
||||
});
|
||||
}).catch(error => {
|
||||
console.error('Failed to load icons on timeline page:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 处理页面导航时的图标重新加载
|
||||
if (typeof window !== 'undefined') {
|
||||
// 监听页面显示事件(包括前进/后退导航)
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
if (event.persisted && window.__iconifyLoader) {
|
||||
// 页面从缓存恢复,重新检查图标状态
|
||||
setTimeout(() => {
|
||||
window.__iconifyLoader.load().catch(console.error);
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user