This commit is contained in:
2025-12-08 01:03:07 +08:00
commit 5c77d25b6d
334 changed files with 71475 additions and 0 deletions

1
src/FooterConfig.html Normal file
View File

@@ -0,0 +1 @@
这里是HTML注入示例你可以在这个文件中添加自定义的HTML内容

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -0,0 +1,167 @@
<script lang="ts">
import { onMount } from "svelte";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
import { getPostUrlByPermalink, getPostUrlBySlug } from "../utils/url-utils";
export let tags: string[];
export let categories: string[];
export let sortedPosts: Post[] = [];
const params = new URLSearchParams(window.location.search);
tags = params.has("tag") ? params.getAll("tag") : [];
categories = params.has("category") ? params.getAll("category") : [];
const uncategorized = params.get("uncategorized");
interface Post {
id: string;
data: {
title: string;
tags: string[];
category?: string;
published: Date;
permalink?: string; // 添加 permalink 字段
};
}
// 辅助函数:根据文章数据生成正确的 URL
function getPostUrl(post: Post): string {
// 如果文章有自定义固定链接,优先使用固定链接
if (post.data.permalink) {
return getPostUrlByPermalink(post.data.permalink);
}
// 否则使用默认的 slug 路径
return getPostUrlBySlug(post.id);
}
interface Group {
year: number;
posts: Post[];
}
let groups: Group[] = [];
function formatDate(date: Date) {
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${month}-${day}`;
}
function formatTag(tagList: string[]) {
return tagList.map((t) => `#${t}`).join(" ");
}
onMount(async () => {
let filteredPosts: Post[] = sortedPosts;
if (tags.length > 0) {
filteredPosts = filteredPosts.filter(
(post) =>
Array.isArray(post.data.tags) &&
post.data.tags.some((tag) => tags.includes(tag)),
);
}
if (categories.length > 0) {
filteredPosts = filteredPosts.filter(
(post) => post.data.category && categories.includes(post.data.category),
);
}
if (uncategorized) {
filteredPosts = filteredPosts.filter((post) => !post.data.category);
}
// 按发布时间倒序排序,确保不受置顶影响
filteredPosts = filteredPosts
.slice()
.sort((a, b) => b.data.published.getTime() - a.data.published.getTime());
const grouped = filteredPosts.reduce(
(acc, post) => {
const year = post.data.published.getFullYear();
if (!acc[year]) {
acc[year] = [];
}
acc[year].push(post);
return acc;
},
{} as Record<number, Post[]>,
);
const groupedPostsArray = Object.keys(grouped).map((yearStr) => ({
year: Number.parseInt(yearStr, 10),
posts: grouped[Number.parseInt(yearStr, 10)],
}));
groupedPostsArray.sort((a, b) => b.year - a.year);
groups = groupedPostsArray;
});
</script>
<div class="card-base px-8 py-6">
{#each groups as group}
<div>
<div class="flex flex-row w-full items-center h-[3.75rem]">
<div class="w-[15%] md:w-[10%] transition text-2xl font-bold text-right text-75">
{group.year}
</div>
<div class="w-[15%] md:w-[10%]">
<div
class="h-3 w-3 bg-none rounded-full outline outline-[var(--primary)] mx-auto
-outline-offset-[2px] z-50 outline-3"
></div>
</div>
<div class="w-[70%] md:w-[80%] transition text-left text-50">
{group.posts.length} {i18n(group.posts.length === 1 ? I18nKey.postCount : I18nKey.postsCount)}
</div>
</div>
{#each group.posts as post}
<a
href={getPostUrl(post)}
aria-label={post.data.title}
class="group btn-plain !block h-10 w-full rounded-lg hover:text-[initial]"
>
<div class="flex flex-row justify-start items-center h-full">
<!-- date -->
<div class="w-[15%] md:w-[10%] transition text-sm text-right text-50">
{formatDate(post.data.published)}
</div>
<!-- dot and line -->
<div class="w-[15%] md:w-[10%] relative dash-line h-full flex items-center">
<div
class="transition-all mx-auto w-1 h-1 rounded group-hover:h-5
bg-[oklch(0.5_0.05_var(--hue))] group-hover:bg-[var(--primary)]
outline outline-4 z-50
outline-[var(--card-bg)]
group-hover:outline-[var(--btn-plain-bg-hover)]
group-active:outline-[var(--btn-plain-bg-active)]"
></div>
</div>
<!-- post title -->
<div
class="w-[70%] md:max-w-[65%] md:w-[65%] text-left font-bold
group-hover:translate-x-1 transition-all group-hover:text-[var(--primary)]
text-75 pr-8 whitespace-nowrap overflow-ellipsis overflow-hidden"
>
{post.data.title}
</div>
<!-- tag list -->
<div
class="hidden md:block md:w-[15%] text-left text-sm transition
whitespace-nowrap overflow-ellipsis overflow-hidden text-30"
>
{formatTag(post.data.tags)}
</div>
</div>
</a>
{/each}
</div>
{/each}
</div>

View File

@@ -0,0 +1,24 @@
---
import { expressiveCodeConfig, siteConfig } from "../config";
---
<!-- 全局配置载体 -->
<div id="config-carrier" data-hue={siteConfig.themeColor.hue} data-hide-code-blocks-during-transition={expressiveCodeConfig.hideDuringThemeTransition ? "true" : "false"}>
</div>
<script is:inline define:vars={{ tocEnable: siteConfig.toc.enable, tocDepth: siteConfig.toc.depth, tocUseJapaneseBadge: siteConfig.toc.useJapaneseBadge }}>
// 将TOC配置传递到前端
window.siteConfig = window.siteConfig || {};
window.siteConfig.toc = {
enable: tocEnable,
depth: tocDepth,
useJapaneseBadge: tocUseJapaneseBadge
};
// 添加调试信息
console.log('TOC Config:', window.siteConfig.toc);
</script>
<!-- 引入Umami Share工具脚本 -->
<script is:inline src="/js/umami-share.js"></script>

View File

@@ -0,0 +1,248 @@
---
interface Props {
children: any;
orientation?: "horizontal" | "vertical";
class?: string;
}
const {
children,
orientation = "horizontal",
class: customClass = "",
} = Astro.props;
---
<div
class={`custom-scrollbar ${orientation === "horizontal" ? "horizontal-scrollbar" : "vertical-scrollbar"} ${customClass}`}
data-orientation={orientation}
>
{children}
</div>
<style>
.custom-scrollbar {
overflow: auto;
position: relative;
}
.horizontal-scrollbar {
overflow-x: auto;
overflow-y: hidden;
}
.vertical-scrollbar {
overflow-y: auto;
overflow-x: hidden;
}
/* 隐藏默认滚动条 */
.custom-scrollbar::-webkit-scrollbar {
display: none;
}
.custom-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* 自定义滚动条轨道 */
.custom-scrollbar::after {
content: "";
position: absolute;
background: #e5e7eb; /* 浅灰色轨道 */
border-radius: 4px;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.horizontal-scrollbar::after {
bottom: 2px;
left: 0;
right: 0;
height: 6px;
}
.vertical-scrollbar::after {
right: 2px;
top: 0;
bottom: 0;
width: 6px;
}
/* 自定义滚动条拇指 */
.custom-scrollbar::before {
content: "";
position: absolute;
background: #6366f1; /* indigo-500 主题色 */
border-radius: 4px;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.horizontal-scrollbar::before {
bottom: 2px;
height: 6px;
min-width: 20px;
}
.vertical-scrollbar::before {
right: 2px;
width: 6px;
min-height: 20px;
}
/* 悬停时显示滚动条 */
.custom-scrollbar:hover::after,
.custom-scrollbar:hover::before {
opacity: 1;
}
/* 夜间模式样式 */
.dark .custom-scrollbar::after {
background: #374151; /* 夜间模式轨道色 */
}
.dark .custom-scrollbar::before {
background: #6366f1; /* indigo-500 夜间模式拇指 */
}
/* 滚动时显示滚动条 */
.custom-scrollbar:active::after,
.custom-scrollbar:active::before,
.custom-scrollbar:focus::after,
.custom-scrollbar:focus::before {
opacity: 1;
}
</style>
<script>
// 获取组件元素
const scrollbarEl = document.currentScript?.parentElement?.querySelector('.custom-scrollbar') as HTMLElement | null;
if (scrollbarEl) {
const el = scrollbarEl;
// 创建滚动条轨道和拇指元素
const track = document.createElement('div');
const thumb = document.createElement('div');
// 设置轨道样式
track.className = 'custom-track';
track.style.position = 'absolute';
track.style.background = '#e5e7eb'; // 浅灰色轨道
track.style.borderRadius = '4px';
track.style.opacity = '0';
track.style.transition = 'opacity 0.3s';
track.style.pointerEvents = 'none';
track.style.zIndex = '10';
// 设置拇指样式
thumb.className = 'custom-thumb';
thumb.style.position = 'absolute';
thumb.style.background = '#6366f1'; // indigo-500 主题色
thumb.style.borderRadius = '4px';
thumb.style.opacity = '0';
thumb.style.transition = 'opacity 0.3s';
thumb.style.pointerEvents = 'none';
thumb.style.zIndex = '10';
// 根据方向设置轨道和拇指的位置和尺寸
if (el.dataset.orientation === 'horizontal') {
track.style.bottom = '2px';
track.style.left = '0';
track.style.right = '0';
track.style.height = '6px';
thumb.style.bottom = '2px';
thumb.style.height = '6px';
thumb.style.minWidth = '20px';
} else {
track.style.right = '2px';
track.style.top = '0';
track.style.bottom = '0';
track.style.width = '6px';
thumb.style.right = '2px';
thumb.style.width = '6px';
thumb.style.minHeight = '20px';
}
// 添加轨道和拇指到滚动条容器
el.appendChild(track);
el.appendChild(thumb);
// 更新滚动条位置和尺寸的函数
function updateScrollbar() {
if (el.dataset.orientation === 'horizontal') {
const scrollWidth = el.scrollWidth;
const clientWidth = el.clientWidth;
const scrollLeft = el.scrollLeft;
if (scrollWidth > clientWidth) {
const thumbWidth = Math.max(20, (clientWidth / scrollWidth) * clientWidth);
const thumbLeft = (scrollLeft / (scrollWidth - clientWidth)) * (clientWidth - thumbWidth);
thumb.style.width = `${thumbWidth}px`;
thumb.style.left = `${thumbLeft}px`;
}
} else {
const scrollHeight = el.scrollHeight;
const clientHeight = el.clientHeight;
const scrollTop = el.scrollTop;
if (scrollHeight > clientHeight) {
const thumbHeight = Math.max(20, (clientHeight / scrollHeight) * clientHeight);
const thumbTop = (scrollTop / (scrollHeight - clientHeight)) * (clientHeight - thumbHeight);
thumb.style.height = `${thumbHeight}px`;
thumb.style.top = `${thumbTop}px`;
}
}
}
// 显示滚动条的函数
function showScrollbar() {
track.style.opacity = '1';
thumb.style.opacity = '1';
}
// 隐藏滚动条的函数
function hideScrollbar() {
track.style.opacity = '0';
thumb.style.opacity = '0';
}
// 夜间模式适配
function updateTheme() {
if (document.documentElement.classList.contains('dark')) {
track.style.background = '#374151'; // 夜间模式轨道色
thumb.style.background = '#6366f1'; // indigo-500 夜间模式拇指
} else {
track.style.background = '#e5e7eb'; // 浅灰色轨道
thumb.style.background = '#6366f1'; // indigo-500 主题色
}
}
// 初始化
updateScrollbar();
updateTheme();
// 事件监听
el.addEventListener('scroll', updateScrollbar);
el.addEventListener('mouseenter', showScrollbar);
el.addEventListener('mouseleave', hideScrollbar);
el.addEventListener('mousedown', showScrollbar);
window.addEventListener('mouseup', hideScrollbar);
// 监听主题变化
const observer = new MutationObserver(updateTheme);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
// 监听窗口大小变化
window.addEventListener('resize', updateScrollbar);
}
</script>

View File

@@ -0,0 +1,54 @@
---
import * as fs from "node:fs";
import * as path from "node:path";
import { footerConfig, profileConfig } from "../config";
import { url } from "../utils/url-utils";
const currentYear = new Date().getFullYear();
// 页脚自定义内容逻辑
let customFooterHtml = "";
if (footerConfig.enable) {
// 优先使用 customHtml如果为空则使用 FooterConfig.html 文件内容
if (footerConfig.customHtml && footerConfig.customHtml.trim() !== "") {
customFooterHtml = footerConfig.customHtml.trim();
} else {
// customHtml 为空时,读取 FooterConfig.html 文件内容
try {
const footerConfigPath = path.join(
process.cwd(),
"src",
"FooterConfig.html",
);
customFooterHtml = fs.readFileSync(footerConfigPath, "utf-8");
// 移除HTML注释
customFooterHtml = customFooterHtml
.replace(/<!--[\s\S]*?-->/g, "")
.trim();
} catch (error) {
console.warn("FooterConfig.html文件读取失败:", error.message);
}
}
}
---
<!--<div class="border-t border-[var(&#45;&#45;primary)] mx-16 border-dashed py-8 max-w-[var(&#45;&#45;page-width)] flex flex-col items-center justify-center px-6">-->
<div class="transition border-t border-black/10 dark:border-white/15 my-10 border-dashed mx-32"></div>
<!--<div class="transition bg-[oklch(92%_0.01_var(&#45;&#45;hue))] dark:bg-black rounded-2xl py-8 mt-4 mb-8 flex flex-col items-center justify-center px-6">-->
<div class="transition border-dashed border-[oklch(85%_0.01_var(--hue))] dark:border-white/15 rounded-2xl mb-12 flex flex-col items-center justify-center px-6">
<div class="transition text-50 text-sm text-center">
{customFooterHtml && (
<div class="mb-2" set:html={customFooterHtml}></div>
)}
&copy; <span id="copyright-year">{currentYear}</span> {profileConfig.name}. All Rights Reserved. /
<a class="transition link text-[var(--primary)] font-medium" href={url('rss/')} onclick={`event.preventDefault(); navigateToPage('${url('rss/')}')`}>RSS</a> /
<a class="transition link text-[var(--primary)] font-medium" href={url('atom/')} onclick={`event.preventDefault(); navigateToPage('${url('atom/')}')`}>Atom</a> /
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href={url('sitemap-index.xml')}>Sitemap</a><br>
Powered by
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href="https://astro.build">Astro</a> &
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href="https://github.com/matsuzaka-yuki/mizuki">Mizuki</a>&nbsp; Version <a class="transition link text-[var(--primary)] font-medium" target="_blank" href="https://github.com/matsuzaka-yuki/mizuki">7.6</a><br>
</div>
</div>

View File

@@ -0,0 +1,3 @@
---
---

View File

@@ -0,0 +1,207 @@
<script lang="ts">
import { onMount } from "svelte";
import { sidebarLayoutConfig, siteConfig } from "../config";
export let currentLayout: "list" | "grid" = "list";
let mounted = false;
let isSmallScreen = false;
let isSwitching = false;
// 检查是否启用双侧边栏
const isBothSidebars = sidebarLayoutConfig.position === "both";
function checkScreenSize() {
isSmallScreen = window.innerWidth < 1200;
if (isSmallScreen) {
currentLayout = "list";
}
}
onMount(() => {
mounted = true;
checkScreenSize();
// 从localStorage读取用户偏好如果没有则使用传入的默认值
const savedLayout = localStorage.getItem("postListLayout");
if (savedLayout && (savedLayout === "list" || savedLayout === "grid")) {
currentLayout = savedLayout;
} else {
// 如果没有保存的偏好使用传入的默认布局从props
// currentLayout已经在声明时设置了默认值
}
// 监听窗口大小变化
window.addEventListener("resize", checkScreenSize);
return () => {
window.removeEventListener("resize", checkScreenSize);
};
});
function switchLayout() {
if (!mounted || isSmallScreen || isSwitching) return;
isSwitching = true;
currentLayout = currentLayout === "list" ? "grid" : "list";
localStorage.setItem("postListLayout", currentLayout);
// 触发自定义事件,通知父组件布局已改变
const event = new CustomEvent("layoutChange", {
detail: { layout: currentLayout },
});
window.dispatchEvent(event);
// 动画完成后重置状态
setTimeout(() => {
isSwitching = false;
}, 500);
}
// 监听布局变化事件
onMount(() => {
const handleCustomEvent = (
event: CustomEvent<{ layout: "list" | "grid" }>,
) => {
currentLayout = event.detail.layout;
};
window.addEventListener("layoutChange", handleCustomEvent as EventListener);
return () => {
window.removeEventListener(
"layoutChange",
handleCustomEvent as EventListener,
);
};
});
// 监听PostPage的布局初始化事件
onMount(() => {
const handleLayoutInit = (
event: CustomEvent<{ layout: "list" | "grid" }>,
) => {
currentLayout = event.detail.layout;
};
const handleSwupEvent = () => {
// Swup页面切换时重新同步布局状态
setTimeout(() => {
console.log("Swup event - syncing layout state");
const savedLayout = localStorage.getItem("postListLayout");
if (savedLayout && (savedLayout === "list" || savedLayout === "grid")) {
currentLayout = savedLayout;
} else {
// 如果没有保存的布局,使用默认布局
const defaultLayout = siteConfig.postListLayout.defaultMode;
currentLayout = defaultLayout;
}
}, 200);
};
// 监听布局初始化事件
window.addEventListener("layoutInit", handleLayoutInit as EventListener);
// 监听Swup页面切换事件
function setupSwupListeners() {
if (typeof window !== "undefined" && (window as any).swup) {
const swup = (window as any).swup;
swup.hooks.on("content:replace", handleSwupEvent);
swup.hooks.on("page:view", handleSwupEvent);
swup.hooks.on("animation:in:end", handleSwupEvent);
console.log("Swup button listeners registered");
} else {
// 降级处理:监听普通页面切换事件
window.addEventListener("popstate", handleSwupEvent);
console.log("Fallback button listeners registered");
}
}
// 延迟设置Swup监听器确保Swup已初始化
setTimeout(setupSwupListeners, 200);
return () => {
window.removeEventListener("layoutInit", handleLayoutInit as EventListener);
if (typeof window !== "undefined" && (window as any).swup) {
const swup = (window as any).swup;
swup.hooks.off("content:replace", handleSwupEvent);
swup.hooks.off("page:view", handleSwupEvent);
swup.hooks.off("animation:in:end", handleSwupEvent);
} else {
window.removeEventListener("popstate", handleSwupEvent);
}
};
});
</script>
{#if mounted && siteConfig.postListLayout.allowSwitch && !isSmallScreen}
<button
aria-label="切换文章列表布局"
class="btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90 flex items-center justify-center theme-switch-btn {isSwitching ? 'switching' : ''}"
on:click={switchLayout}
disabled={isSwitching}
title={currentLayout === 'list' ? '切换到网格模式' : '切换到列表模式'}
>
{#if currentLayout === 'list'}
<!-- 列表图标 -->
<svg class="w-5 h-5 icon-transition" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 6h16v2H4zm0 5h16v2H4zm0 5h16v2H4z"/>
</svg>
{:else}
<!-- 网格图标 -->
<svg class="w-5 h-5 icon-transition" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 3h7v7H3V3zm0 11h7v7H3v-7zm11-11h7v7h-7V3zm0 11h7v7h-7v-7z"/>
</svg>
{/if}
</button>
{/if}
<style>
/* 确保主题切换按钮的背景色即时更新 */
.theme-switch-btn::before {
transition: transform 75ms ease-out, background-color 0ms !important;
}
/* 图标过渡动画 */
.icon-transition {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
}
/* 切换中的按钮动画 */
.switching {
pointer-events: none;
}
.switching .icon-transition {
animation: iconRotate 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes iconRotate {
0% {
transform: rotate(0deg) scale(1);
opacity: 1;
}
50% {
transform: rotate(180deg) scale(0.8);
opacity: 0.5;
}
100% {
transform: rotate(360deg) scale(1);
opacity: 1;
}
}
/* 悬停效果增强 */
.theme-switch-btn:not(.switching):hover .icon-transition {
transform: scale(1.1);
}
/* 按钮禁用状态 */
.theme-switch-btn:disabled {
cursor: not-allowed;
opacity: 0.7;
}
</style>

View File

@@ -0,0 +1,95 @@
<script lang="ts">
import { DARK_MODE, LIGHT_MODE } from "@constants/constants.ts";
import Icon from "@iconify/svelte";
import { getStoredTheme, setTheme } from "@utils/setting-utils.ts";
import type { LIGHT_DARK_MODE } from "@/types/config.ts";
const seq: LIGHT_DARK_MODE[] = [LIGHT_MODE, DARK_MODE];
let mode: LIGHT_DARK_MODE = $state(getStoredTheme());
let isChanging = false;
function switchScheme(newMode: LIGHT_DARK_MODE) {
// 防止连续快速点击
if (isChanging) return;
isChanging = true;
mode = newMode;
setTheme(newMode);
// 50ms 后重置状态,防止过快切换
setTimeout(() => {
isChanging = false;
}, 50);
}
function toggleScheme() {
if (isChanging) return;
let i = 0;
for (; i < seq.length; i++) {
if (seq[i] === mode) {
break;
}
}
switchScheme(seq[(i + 1) % seq.length]);
}
// 添加Swup钩子监听确保在页面切换后同步主题状态
if (typeof window !== "undefined") {
// 监听Swup的内容替换事件
const handleContentReplace = () => {
// 使用requestAnimationFrame确保在下一帧更新状态避免渲染冲突
requestAnimationFrame(() => {
const newMode = getStoredTheme();
if (mode !== newMode) {
mode = newMode;
}
});
};
// 检查Swup是否已经加载
if ((window as any).swup && (window as any).swup.hooks) {
(window as any).swup.hooks.on("content:replace", handleContentReplace);
} else {
document.addEventListener("swup:enable", () => {
if ((window as any).swup && (window as any).swup.hooks) {
(window as any).swup.hooks.on("content:replace", handleContentReplace);
}
});
}
// 页面加载完成后也同步一次状态
document.addEventListener("DOMContentLoaded", () => {
requestAnimationFrame(() => {
const newMode = getStoredTheme();
if (mode !== newMode) {
mode = newMode;
}
});
});
}
</script>
<div class="relative z-50">
<button
aria-label="Light/Dark Mode"
class="relative btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90 theme-switch-btn"
id="scheme-switch"
onclick={toggleScheme}
data-mode={mode}
>
<div class="absolute transition-all duration-300 ease-in-out" class:opacity-0={mode !== LIGHT_MODE} class:rotate-180={mode !== LIGHT_MODE}>
<Icon icon="material-symbols:wb-sunny-outline-rounded" class="text-[1.25rem]"></Icon>
</div>
<div class="absolute transition-all duration-300 ease-in-out" class:opacity-0={mode !== DARK_MODE} class:rotate-180={mode !== DARK_MODE}>
<Icon icon="material-symbols:dark-mode-outline-rounded" class="text-[1.25rem]"></Icon>
</div>
</button>
</div>
<style>
/* 确保主题切换按钮的背景色即时更新 */
.theme-switch-btn::before {
transition: transform 75ms ease-out, background-color 0ms !important;
}
</style>

View File

@@ -0,0 +1,651 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
import { navigateToPage } from "../utils/navigation-utils";
import { panelManager } from "../utils/panel-manager.js";
let tocItems: Array<{
id: string;
text: string;
level: number;
badge?: string;
}> = [];
let postItems: Array<{
title: string;
url: string;
category?: string;
pinned?: boolean;
}> = [];
let activeId = "";
let observer: IntersectionObserver;
let isHomePage = false;
let swupReady = false;
let useJapaneseBadge = false;
let tocDepth = 3;
const togglePanel = async () => {
await panelManager.togglePanel("mobile-toc-panel");
};
const setPanelVisibility = async (show: boolean): Promise<void> => {
await panelManager.togglePanel("mobile-toc-panel", show);
};
const generateTOC = () => {
// 获取配置
useJapaneseBadge = (window as any).siteConfig?.toc?.useJapaneseBadge || false;
tocDepth = (window as any).siteConfig?.toc?.depth || 3;
const headings = document.querySelectorAll("h1, h2, h3, h4, h5, h6");
const items: Array<{
id: string;
text: string;
level: number;
badge?: string;
}> = [];
const japaneseHiragana = [
"ア",
"イ",
"ウ",
"エ",
"オ",
"カ",
"キ",
"ク",
"ケ",
"コ",
"サ",
"シ",
"ス",
"セ",
"ソ",
"タ",
"チ",
"ツ",
"テ",
"ト",
];
let h1Count = 0;
headings.forEach((heading) => {
if (heading.id) {
const level = Number.parseInt(heading.tagName.charAt(1), 10);
// 根据depth配置过滤标题
if (level > tocDepth) {
return;
}
const text = (heading.textContent || "").replace(/#+\s*$/, "");
let badge = "";
// 只为H1标题生成badge
if (level === 1) {
h1Count++;
if (useJapaneseBadge && h1Count - 1 < japaneseHiragana.length) {
badge = japaneseHiragana[h1Count - 1];
} else {
badge = h1Count.toString();
}
}
items.push({ id: heading.id, text, level, badge });
}
});
tocItems = items;
};
const generatePostList = () => {
// 查找所有文章卡片
const postCards = document.querySelectorAll(".card-base");
const items: Array<{
title: string;
url: string;
category?: string;
pinned?: boolean;
}> = [];
postCards.forEach((card) => {
// 查找标题链接
const titleLink = card.querySelector('a[href*="/posts/"].transition.group');
// 查找分类链接
const categoryLink = card.querySelector('a[href*="/categories/"].link-lg');
// 查找置顶图标
const pinnedIcon = titleLink?.querySelector('svg[data-icon="mdi:pin"]');
if (titleLink) {
const href = titleLink.getAttribute("href");
const title = titleLink.textContent?.replace(/\s+/g, " ").trim() || "";
const category = categoryLink?.textContent?.trim() || "";
const pinned = !!pinnedIcon;
if (href && title) {
items.push({ title, url: href, category, pinned });
}
}
});
postItems = items;
};
const checkIsHomePage = () => {
const pathname = window.location.pathname;
// 检查是否为首页或首页的分页页面
// 分页格式:/, /2/, /3/, 等等
isHomePage =
pathname === "/" || pathname === "" || /^\/\d+\/?$/.test(pathname);
};
const scrollToHeading = (id: string) => {
const element = document.getElementById(id);
if (element) {
// 关闭面板
setPanelVisibility(false);
// 滚动到目标位置,考虑导航栏高度
const offset = 80;
const elementPosition = element.offsetTop - offset;
window.scrollTo({
top: elementPosition,
behavior: "smooth",
});
}
};
const navigateToPost = (url: string) => {
// 关闭面板
setPanelVisibility(false);
// 使用统一的导航工具函数,实现无刷新跳转
navigateToPage(url);
};
const updateActiveHeading = () => {
const headings = document.querySelectorAll("h1, h2, h3, h4, h5, h6");
const scrollTop = window.scrollY;
const offset = 100;
let currentActiveId = "";
headings.forEach((heading) => {
if (heading.id) {
const elementTop = (heading as HTMLElement).offsetTop - offset;
if (scrollTop >= elementTop) {
currentActiveId = heading.id;
}
}
});
activeId = currentActiveId;
};
const setupIntersectionObserver = () => {
const headings = document.querySelectorAll("h1, h2, h3, h4, h5, h6");
if (observer) {
observer.disconnect();
}
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
activeId = entry.target.id;
}
});
},
{
rootMargin: "-80px 0px -80% 0px",
threshold: 0,
},
);
headings.forEach((heading) => {
if (heading.id) {
observer.observe(heading);
}
});
};
let swupListenersRegistered = false;
const setupSwupListeners = () => {
if (
typeof window !== "undefined" &&
(window as any).swup &&
!swupListenersRegistered
) {
const swup = (window as any).swup;
// 只监听页面视图事件,避免重复触发
swup.hooks.on("page:view", () => {
// 延迟执行,确保页面已完全加载
setTimeout(() => {
init();
}, 200);
});
swupListenersRegistered = true;
console.log("MobileTOC Swup listener registered");
} else if (!swupListenersRegistered) {
// 降级处理:监听普通页面切换事件
window.addEventListener("popstate", () => {
setTimeout(init, 200);
});
swupListenersRegistered = true;
console.log("MobileTOC fallback listener registered");
}
};
const checkSwupAvailability = () => {
if (typeof window !== "undefined") {
// 检查Swup是否已加载
swupReady = !!(window as any).swup;
// 如果Swup还未加载监听其加载事件
if (!swupReady) {
const checkSwup = () => {
if ((window as any).swup) {
swupReady = true;
document.removeEventListener("swup:enable", checkSwup);
// Swup加载完成后设置监听器
setupSwupListeners();
}
};
// 监听Swup启用事件
document.addEventListener("swup:enable", checkSwup);
// 设置超时检查
setTimeout(() => {
if ((window as any).swup) {
swupReady = true;
document.removeEventListener("swup:enable", checkSwup);
// Swup加载完成后设置监听器
setupSwupListeners();
}
}, 1000);
} else {
// Swup已经加载直接设置监听器
setupSwupListeners();
}
}
};
const init = () => {
checkIsHomePage();
checkSwupAvailability();
if (isHomePage) {
generatePostList();
} else {
generateTOC();
setupIntersectionObserver();
updateActiveHeading();
}
};
onMount(() => {
// 延迟初始化,确保页面内容已加载
setTimeout(init, 100);
// 监听滚动事件作为备用
window.addEventListener("scroll", updateActiveHeading);
return () => {
if (observer) {
observer.disconnect();
}
window.removeEventListener("scroll", updateActiveHeading);
// 清理Swup事件监听器
if (typeof window !== "undefined" && (window as any).swup) {
const swup = (window as any).swup;
swup.hooks.off("page:view");
}
// 清理popstate事件监听器
window.removeEventListener("popstate", init);
swupListenersRegistered = false;
};
});
// 导出初始化函数供外部调用
if (typeof window !== "undefined") {
(window as any).mobileTOCInit = init;
}
</script>
<!-- TOC toggle button for mobile -->
<button
on:click={togglePanel}
aria-label="Table of Contents"
id="mobile-toc-switch"
class="btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90 lg:!hidden theme-switch-btn"
>
<Icon icon="material-symbols:format-list-bulleted" class="text-[1.25rem]" />
</button>
<!-- Mobile TOC Panel -->
<div
id="mobile-toc-panel"
class="float-panel float-panel-closed mobile-toc-panel absolute md:w-[20rem] w-[calc(100vw-2rem)]
top-20 left-4 md:left-[unset] right-4 shadow-2xl rounded-2xl p-4"
>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold text-[var(--primary)]">{isHomePage ? i18n(I18nKey.postList) : i18n(I18nKey.tableOfContents)}</h3>
<button
on:click={togglePanel}
aria-label="Close TOC"
class="btn-plain rounded-lg h-8 w-8 active:scale-90 theme-switch-btn"
>
<Icon icon="material-symbols:close" class="text-[1rem]" />
</button>
</div>
{#if isHomePage}
{#if postItems.length === 0}
<div class="text-center py-8 text-black/50 dark:text-white/50">
<Icon icon="material-symbols:article-outline" class="text-2xl mb-2" />
<p>暂无文章</p>
</div>
{:else}
<div class="post-content">
{#each postItems as post}
<button
on:click={() => navigateToPost(post.url)}
class="post-item"
>
<div class="post-title">
{#if post.pinned}
<Icon icon="mdi:pin" class="pinned-icon" />
{/if}
{post.title}
</div>
{#if post.category}
<div class="post-category">{post.category}</div>
{/if}
</button>
{/each}
</div>
{/if}
{:else}
{#if tocItems.length === 0}
<div class="text-center py-8 text-black/50 dark:text-white/50">
<p>当前页面没有目录</p>
</div>
{:else}
<div class="toc-content">
{#each tocItems as item}
<button
on:click={() => scrollToHeading(item.id)}
class="toc-item level-{item.level} {activeId === item.id ? 'active' : ''}"
class:active={activeId === item.id}
>
{#if item.level === 1}
<span class="badge">{item.badge}</span>
{:else if item.level === 2}
<span class="dot-square"></span>
{:else}
<span class="dot-small"></span>
{/if}
<span class="toc-text">{item.text}</span>
</button>
{/each}
</div>
{/if}
{/if}
</div>
<style>
.mobile-toc-panel {
max-height: calc(100vh - 120px);
overflow-y: auto;
background: var(--card-bg);
border: 1px solid var(--line-color);
backdrop-filter: blur(10px);
}
/* 确保主题切换按钮的背景色即时更新 */
:global(.theme-switch-btn)::before {
transition: transform 75ms ease-out, background-color 0ms !important;
}
.toc-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.post-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.toc-item {
display: flex;
align-items: center;
width: 100%;
text-align: left;
padding: 8px 12px;
border-radius: 8px;
transition: all 0.2s ease;
border: none;
background: transparent;
cursor: pointer;
color: rgba(0, 0, 0, 0.75);
font-size: 0.9rem;
line-height: 1.4;
}
:global(.dark) .toc-item {
color: rgba(255, 255, 255, 0.75);
}
.toc-item:hover {
background: var(--btn-plain-bg-hover);
color: var(--primary);
}
.toc-item.active {
background: var(--btn-plain-bg-active);
color: var(--primary);
font-weight: 600;
border-left: 3px solid var(--primary);
padding-left: 9px;
}
/* 不同级别的标题缩进 */
.toc-item.level-1 {
padding-left: 12px;
font-weight: 600;
font-size: 1rem;
gap: 8px;
}
.toc-item.level-2 {
padding-left: 28px;
gap: 6px;
}
.toc-item.level-3 {
padding-left: 36px;
font-size: 0.85rem;
gap: 6px;
}
.toc-item.level-4 {
padding-left: 44px;
font-size: 0.8rem;
gap: 6px;
}
.toc-item.level-5,
.toc-item.level-6 {
padding-left: 52px;
font-size: 0.75rem;
color: rgba(0, 0, 0, 0.5);
gap: 6px;
}
:global(.dark) .toc-item.level-5,
:global(.dark) .toc-item.level-6 {
color: rgba(255, 255, 255, 0.5);
}
.toc-item.level-1.active {
padding-left: 9px;
}
.toc-item.level-2.active {
padding-left: 25px;
}
.toc-item.level-3.active {
padding-left: 33px;
}
.toc-item.level-4.active {
padding-left: 41px;
}
.toc-item.level-5.active,
.toc-item.level-6.active {
padding-left: 49px;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 4px;
border-radius: 6px;
background: var(--toc-badge-bg);
color: var(--btn-content);
font-size: 0.8rem;
font-weight: 600;
flex-shrink: 0;
line-height: 1;
}
.dot-square {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 2px;
background: var(--toc-badge-bg);
flex-shrink: 0;
}
.dot-small {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 2px;
background: rgba(0, 0, 0, 0.05);
flex-shrink: 0;
}
:global(.dark) .dot-small {
background: rgba(255, 255, 255, 0.1);
}
.toc-text {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.post-item {
display: block;
width: 100%;
text-align: left;
padding: 12px;
border-radius: 8px;
transition: all 0.2s ease;
border: none;
background: transparent;
cursor: pointer;
border: 1px solid var(--line-color);
}
.post-item:hover {
background: var(--btn-plain-bg-hover);
border-color: var(--primary);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.post-title {
font-size: 0.9rem;
font-weight: 600;
color: rgba(0, 0, 0, 0.75);
margin-bottom: 4px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.dark) .post-title {
color: rgba(255, 255, 255, 0.75);
}
.post-category {
font-size: 0.75rem;
color: rgba(0, 0, 0, 0.5);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.dark) .post-category {
color: rgba(255, 255, 255, 0.5);
}
:global(.pinned-icon) {
display: inline;
color: var(--primary);
font-size: 1.25rem;
margin-right: 0.5rem;
transform: translateY(-0.125rem);
vertical-align: middle;
}
.post-item:hover .post-title {
color: var(--primary);
}
.post-item:hover .post-category {
color: rgba(0, 0, 0, 0.75);
}
:global(.dark) .post-item:hover .post-category {
color: rgba(255, 255, 255, 0.75);
}
/* 滚动条样式 */
.mobile-toc-panel::-webkit-scrollbar {
width: 4px;
}
.mobile-toc-panel::-webkit-scrollbar-track {
background: transparent;
}
.mobile-toc-panel::-webkit-scrollbar-thumb {
background: var(--line-color);
border-radius: 2px;
}
.mobile-toc-panel::-webkit-scrollbar-thumb:hover {
background: var(--text-color-25);
}
</style>

261
src/components/Navbar.astro Normal file
View File

@@ -0,0 +1,261 @@
---
import { Icon } from "astro-icon/components";
import { navBarConfig, siteConfig } from "../config";
import { LinkPresets } from "../constants/link-presets";
import { LinkPreset, type NavBarLink } from "../types/config";
import { url } from "../utils/url-utils";
import LayoutSwitchButton from "./LayoutSwitchButton.svelte";
import LightDarkSwitch from "./LightDarkSwitch.svelte";
import MobileTOC from "./MobileTOC.svelte";
import Search from "./Search.svelte";
import WallpaperSwitch from "./WallpaperSwitch.svelte";
import DisplaySettings from "./widget/DisplaySettings.svelte";
import DropdownMenu from "./widget/DropdownMenu.astro";
import NavMenuPanel from "./widget/NavMenuPanel.astro";
const className = Astro.props.class;
// 获取导航栏透明模式配置
const navbarTransparentMode =
siteConfig.banner?.navbar?.transparentMode || "semi";
// 获取整体布局方案切换按钮显示设置
const modeSwitchDisplay =
siteConfig.wallpaperMode?.showModeSwitchOnMobile || "desktop";
// 检查是否为首页
const isHomePage = Astro.url.pathname === "/" || Astro.url.pathname === "";
let links: NavBarLink[] = navBarConfig.links.map(
(item: NavBarLink | LinkPreset): NavBarLink => {
if (typeof item === "number") {
return LinkPresets[item];
}
return item;
},
);
---
<div id="navbar" class="z-50 onload-animation" data-transparent-mode={navbarTransparentMode} data-is-home={isHomePage}>
<div class="absolute h-8 left-0 right-0 -top-8 bg-[var(--card-bg)] transition"></div> <!-- used for onload animation -->
<div class:list={[className, "!overflow-visible max-w-[var(--page-width)] h-[4.5rem] mx-auto flex items-center justify-between px-4"]}>
<a href={url('/')} class="btn-plain scale-animation rounded-lg h-[3.25rem] px-5 font-bold active:scale-95">
<div class="flex flex-row items-center text-md">
{/* 使用navbarTitle配置或默认配置 */}
{siteConfig.navbarTitle ? (
<>
<img src={url(siteConfig.navbarTitle.icon || "/assets/home/home.png")} alt={siteConfig.navbarTitle.text} class="h-[1.75rem] w-[1.75rem] mb-1 mr-2 object-contain" loading="lazy" />
<span class="dark:text-white text-black">{siteConfig.navbarTitle.text}</span>
</>
) : (
<>
<Icon name="material-symbols:home-pin-outline" class="text-[1.75rem] mb-1 mr-2" />
<span class="dark:text-white text-black">{siteConfig.title}</span>
</>
)}
</div>
</a>
<div class="hidden md:flex items-center space-x-1">
{links.map((l) => {
return <DropdownMenu link={l} />;
})}
</div>
<div class="flex">
<!--<SearchPanel client:load>-->
<Search client:only="svelte"></Search>
{siteConfig.toc.enable && <MobileTOC client:only="svelte"></MobileTOC>}
{!siteConfig.themeColor.fixed && (
<button aria-label="Display Settings" class="btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90" id="display-settings-switch">
<Icon name="material-symbols:palette-outline" class="text-[1.25rem]"></Icon>
</button>
)}
<LayoutSwitchButton client:only="svelte" currentLayout={siteConfig.postListLayout.defaultMode}></LayoutSwitchButton>
<!-- 整体布局方案切换按钮:根据配置决定在哪些设备上显示 -->
<div class:list={[
modeSwitchDisplay === "off" ? "hidden" :
modeSwitchDisplay === "mobile" ? "block md:hidden" :
modeSwitchDisplay === "desktop" ? "hidden md:block" :
modeSwitchDisplay === "both" ? "block" : "hidden md:block"
]}>
<WallpaperSwitch client:only="svelte"></WallpaperSwitch>
</div>
<LightDarkSwitch client:only="svelte"></LightDarkSwitch>
<button aria-label="Menu" name="Nav Menu" class="btn-plain scale-animation rounded-lg w-11 h-11 active:scale-90 md:!hidden" id="nav-menu-switch">
<Icon name="material-symbols:menu-rounded" class="text-[1.25rem]"></Icon>
</button>
</div>
<NavMenuPanel links={links}></NavMenuPanel>
<DisplaySettings client:only="svelte"></DisplaySettings>
</div>
</div>
<script>
// function switchTheme() {
// if (localStorage.theme === 'dark') {
// document.documentElement.classList.remove('dark');
// localStorage.theme = 'light';
// } else {
// document.documentElement.classList.add('dark');
// localStorage.theme = 'dark';
// }
// }
async function loadButtonScript() {
try {
// 导入面板管理器
const { panelManager } = await import('../utils/panel-manager.js');
let settingBtn = document.getElementById("display-settings-switch");
if (settingBtn) {
settingBtn.onclick = async function () {
await panelManager.togglePanel('display-setting');
};
}
let menuBtn = document.getElementById("nav-menu-switch");
if (menuBtn) {
menuBtn.onclick = async function () {
await panelManager.togglePanel('nav-menu-panel');
};
}
} catch (error) {
console.error('Failed to load panel manager:', error);
// 备用逻辑
let settingBtn = document.getElementById("display-settings-switch");
if (settingBtn) {
settingBtn.onclick = function () {
let settingPanel = document.getElementById("display-setting");
if (settingPanel) {
settingPanel.classList.toggle("float-panel-closed");
}
};
}
let menuBtn = document.getElementById("nav-menu-switch");
if (menuBtn) {
menuBtn.onclick = function () {
let menuPanel = document.getElementById("nav-menu-panel");
if (menuPanel) {
menuPanel.classList.toggle("float-panel-closed");
}
};
}
}
}
loadButtonScript();
// 为semifull模式添加滚动检测逻辑
function initSemifullScrollDetection() {
const navbar = document.getElementById('navbar');
if (!navbar) return;
const transparentMode = navbar.getAttribute('data-transparent-mode');
if (transparentMode !== 'semifull') return;
const isHomePage = navbar.getAttribute('data-is-home') === 'true';
// 如果不是首页,移除滚动事件监听器并设置为半透明状态
if (!isHomePage) {
// 移除之前的滚动事件监听器(如果存在)
// @ts-ignore
if (window.semifullScrollHandler) {
// @ts-ignore
window.removeEventListener('scroll', window.semifullScrollHandler);
// @ts-ignore
window.semifullScrollHandler = null;
}
// 设置为半透明状态
navbar.classList.add('scrolled');
return;
}
// 移除现有的scrolled类重置状态
navbar.classList.remove('scrolled');
let ticking = false;
function updateNavbarState() {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const threshold = 50; // 滚动阈值,可以根据需要调整
if (navbar && scrollTop > threshold) {
navbar.classList.add('scrolled');
} else if (navbar) {
navbar.classList.remove('scrolled');
}
ticking = false;
}
function requestTick() {
if (!ticking) {
requestAnimationFrame(updateNavbarState);
ticking = true;
}
}
// 移除之前的滚动事件监听器(如果存在)
// @ts-ignore
if (window.semifullScrollHandler) {
// @ts-ignore
window.removeEventListener('scroll', window.semifullScrollHandler);
}
// 保存新的事件处理器引用
// @ts-ignore
window.semifullScrollHandler = requestTick;
// 监听滚动事件
window.addEventListener('scroll', requestTick, { passive: true });
// 初始化状态
updateNavbarState();
}
// 将函数暴露到全局对象,供页面切换时调用
// @ts-ignore
window.initSemifullScrollDetection = initSemifullScrollDetection;
// 页面加载完成后初始化滚动检测
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSemifullScrollDetection);
} else {
initSemifullScrollDetection();
}
</script>
{import.meta.env.PROD && <script is:inline define:vars={{scriptUrl: url('/pagefind/pagefind.js')}}>
async function loadPagefind() {
try {
const response = await fetch(scriptUrl, { method: 'HEAD' });
if (!response.ok) {
throw new Error(`Pagefind script not found: ${response.status}`);
}
const pagefind = await import(scriptUrl);
await pagefind.options({
excerptLength: 20
});
window.pagefind = pagefind;
document.dispatchEvent(new CustomEvent('pagefindready'));
console.log('Pagefind loaded and initialized successfully, event dispatched.');
} catch (error) {
console.error('Failed to load Pagefind:', error);
window.pagefind = {
search: () => Promise.resolve({ results: [] }),
options: () => Promise.resolve(),
};
document.dispatchEvent(new CustomEvent('pagefindloaderror'));
console.log('Pagefind load error, event dispatched.');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadPagefind);
} else {
loadPagefind();
}
</script>}

View File

@@ -0,0 +1,636 @@
---
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
export interface Props {
encryptedContent: string;
passwordHash: string;
}
const { encryptedContent, passwordHash } = Astro.props;
---
<div id="password-protection" class="password-protection">
<div class="password-container">
<div class="lock-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6z" fill="currentColor"/>
</svg>
</div>
<h2>{i18n(I18nKey.passwordProtectedTitle)}</h2>
<p>{i18n(I18nKey.passwordProtectedDescription)}</p>
<div class="password-input-group">
<input
type="password"
id="password-input"
placeholder={i18n(I18nKey.passwordPlaceholder)}
class="password-input"
/>
<button id="unlock-btn" class="unlock-button">{i18n(I18nKey.passwordUnlock)}</button>
</div>
<div id="error-message" class="error-message" style="display: none;">{i18n(I18nKey.passwordIncorrect)}</div>
</div>
</div>
<div id="decrypted-content" class="decrypted-content" style="display: none;"></div>
<style>
.password-protection {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
padding: 2rem;
}
.password-container {
text-align: center;
max-width: 400px;
width: 100%;
padding: 2rem;
border-radius: 12px;
background: transparent;
border: 1px solid var(--line-divider);
box-shadow: none;
}
.lock-icon {
margin-bottom: 1rem;
color: var(--primary);
}
.password-container h2 {
margin-bottom: 0.5rem;
color: rgba(0, 0, 0, 0.85);
font-size: 1.5rem;
}
:global(.dark) .password-container h2 {
color: rgba(255, 255, 255, 0.85);
}
.password-container p {
margin-bottom: 1.5rem;
color: rgba(0, 0, 0, 0.75);
opacity: 0.8;
}
:global(.dark) .password-container p {
color: rgba(255, 255, 255, 0.75);
}
.password-input-group {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
align-items: stretch;
}
.password-input {
flex: 1;
min-width: 0; /* 允许输入框在需要时缩小 */
padding: 0.75rem 1rem;
border: 1px solid var(--line-divider);
border-radius: 8px;
background: transparent;
color: rgba(0, 0, 0, 0.85);
font-size: 1rem;
transition: border-color 0.2s ease;
}
:global(.dark) .password-input {
color: rgba(255, 255, 255, 0.85);
}
.password-input::placeholder {
color: rgba(0, 0, 0, 0.5);
}
:global(.dark) .password-input::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.password-input:focus {
outline: none;
border-color: var(--primary);
}
.unlock-button {
padding: 0.75rem 1.5rem;
background: transparent;
color: var(--primary);
border: 1px solid var(--primary);
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: border-color 0.2s, color 0.2s, background 0.2s;
white-space: nowrap;
flex-shrink: 0;
min-width: fit-content;
max-width: max-content;
}
.unlock-button:hover {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.unlock-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.5rem;
}
.decrypted-content {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 桌面端优化 - 确保按钮布局正常 */
@media (min-width: 769px) {
.password-input-group {
flex-wrap: nowrap;
}
.unlock-button {
max-width: 40%;
}
}
/* 移动端适配 */
@media (max-width: 768px) {
.password-protection {
padding: 1rem;
min-height: 50vh;
}
.password-container {
max-width: none;
width: 100%;
padding: 1.5rem;
margin: 0 0.5rem;
}
.password-container h2 {
font-size: 1.25rem;
margin-bottom: 0.75rem;
}
.password-container p {
font-size: 0.9rem;
margin-bottom: 1.25rem;
}
.password-input-group {
flex-direction: column;
gap: 0.75rem;
}
.password-input {
padding: 0.875rem 1rem;
font-size: 1rem;
width: 100%;
}
.unlock-button {
padding: 0.875rem 1rem;
font-size: 1rem;
max-width: 100%;
width: 100%;
white-space: nowrap;
}
.error-message {
font-size: 0.8rem;
text-align: center;
}
}
/* 小屏手机适配 */
@media (max-width: 480px) {
.password-protection {
padding: 0.75rem;
}
.password-container {
padding: 1.25rem;
margin: 0 0.25rem;
}
.password-container h2 {
font-size: 1.125rem;
}
.password-container p {
font-size: 0.85rem;
}
.password-input {
padding: 0.75rem 0.875rem;
font-size: 0.95rem;
}
.unlock-button {
padding: 0.75rem 0.875rem;
font-size: 0.95rem;
}
}
</style>
<script is:inline define:vars={{
encryptedContent,
passwordHash,
i18nUnlocking: i18n(I18nKey.passwordUnlocking),
i18nIncorrect: i18n(I18nKey.passwordIncorrect),
i18nDecryptError: i18n(I18nKey.passwordDecryptError),
i18nUnlock: i18n(I18nKey.passwordUnlock),
i18nCopyFailed: i18n(I18nKey.copyFailed),
i18nPasswordRequired: i18n(I18nKey.passwordRequired),
i18nDecryptionError: i18n(I18nKey.decryptionError),
i18nPasswordDecryptRetry: i18n(I18nKey.passwordDecryptRetry)
}}>
// 导入加密库 - 使用本地文件
async function loadCryptoLibraries() {
if (typeof CryptoJS === 'undefined') {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = '/assets/js/crypto-js.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
if (typeof bcrypt === 'undefined') {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = '/assets/js/bcrypt.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
}
// 加载语法高亮库
async function loadSyntaxHighlighter() {
if (typeof hljs === 'undefined') {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = '/assets/js/highlight.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
await new Promise(resolve => setTimeout(resolve, 100));
}
}
// 为代码块添加样式和功能的函数 - 使用highlight.js进行语法高亮但使用全局expressive-code样式
async function enhanceCodeBlocks(container) {
// 加载语法高亮库
await loadSyntaxHighlighter();
const codeBlocks = container.querySelectorAll('pre code');
codeBlocks.forEach(codeElement => {
const preElement = codeElement.parentElement;
if (!preElement) return;
// 获取语言信息
const className = codeElement.className || '';
const langMatch = className.match(/language-(\w+)/);
const language = langMatch ? langMatch[1] : 'text';
// 获取原始代码文本
const codeText = codeElement.textContent || '';
// 使用 highlight.js 进行语法高亮
let highlightedCode = codeText;
if (typeof hljs !== 'undefined') {
try {
if (language && language !== 'text') {
const result = hljs.highlight(codeText, { language: language });
highlightedCode = result.value;
} else {
const result = hljs.highlightAuto(codeText);
highlightedCode = result.value;
}
} catch (e) {
console.warn('Syntax highlight failed:', e);
highlightedCode = codeText;
}
}
// 创建expressive-code样式的包装结构
const wrapper = document.createElement('div');
wrapper.className = 'expressive-code';
const frame = document.createElement('div');
frame.className = 'frame';
// 创建新的pre元素
const newPre = document.createElement('pre');
newPre.className = 'astro-code';
newPre.setAttribute('data-language', language);
newPre.setAttribute('tabindex', '0');
// 创建新的code元素
const newCode = document.createElement('code');
newCode.className = `language-${language}`;
// 将高亮后的代码按行分割并添加.line类模拟expressive-code的行结构
const lines = highlightedCode.split('\n');
lines.forEach((line, index) => {
const lineSpan = document.createElement('span');
lineSpan.className = 'line';
lineSpan.innerHTML = line || ' '; // 空行也要保留
newCode.appendChild(lineSpan);
// 除了最后一行,都添加换行符
if (index < lines.length - 1) {
newCode.appendChild(document.createTextNode('\n'));
}
});
newPre.appendChild(newCode);
// 将 pre 从原位置移除并重新包装
const parent = preElement.parentNode;
parent.insertBefore(wrapper, preElement);
parent.removeChild(preElement);
frame.appendChild(newPre);
// 添加复制按钮
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-btn';
copyBtn.setAttribute('aria-label', 'Copy code');
copyBtn.setAttribute('type', 'button');
const copyBtnIcon = document.createElement('div');
copyBtnIcon.className = 'copy-btn-icon';
// 复制图标SVG
const copyIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
copyIcon.setAttribute('viewBox', '0 -960 960 960');
copyIcon.setAttribute('class', 'copy-btn-icon copy-icon');
const copyPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
copyPath.setAttribute('d', 'M368.37-237.37q-34.48 0-58.74-24.26-24.26-24.26-24.26-58.74v-474.26q0-34.48 24.26-58.74 24.26-24.26 58.74-24.26h378.26q34.48 0 58.74 24.26 24.26 24.26 24.26 58.74v474.26q0 34.48-24.26 58.74-24.26 24.26-58.74 24.26H368.37Zm0-83h378.26v-474.26H368.37v474.26Zm-155 238q-34.48 0-58.74-24.26-24.26-24.26-24.26-58.74v-557.26h83v557.26h461.26v83H213.37Z');
copyIcon.appendChild(copyPath);
// 成功图标SVG
const successIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
successIcon.setAttribute('viewBox', '0 -960 960 960');
successIcon.setAttribute('class', 'copy-btn-icon success-icon');
const successPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
successPath.setAttribute('d', 'm389-377.13 294.7-294.7q12.58-12.67 29.52-12.67 16.93 0 29.61 12.67 12.67 12.68 12.67 29.53 0 16.86-12.28 29.14L419.07-288.41q-12.59 12.67-29.52 12.67-16.94 0-29.62-12.67L217.41-430.93q-12.67-12.68-12.79-29.45-.12-16.77 12.55-29.45 12.68-12.67 29.62-12.67 16.93 0 29.28 12.67L389-377.13Z');
successIcon.appendChild(successPath);
copyBtnIcon.appendChild(copyIcon);
copyBtnIcon.appendChild(successIcon);
copyBtn.appendChild(copyBtnIcon);
// 复制功能 - 使用与全局一致的逻辑
copyBtn.addEventListener('click', async () => {
try {
// 获取所有.line元素的文本内容不包括行号
const lineElements = newCode.querySelectorAll('.line');
const code = Array.from(lineElements)
.map(el => el.textContent)
.join('\n');
await navigator.clipboard.writeText(code);
copyBtn.classList.add('success');
const timeoutId = copyBtn.getAttribute('data-timeout-id');
if (timeoutId) {
clearTimeout(parseInt(timeoutId));
}
const newTimeoutId = setTimeout(() => {
copyBtn.classList.remove('success');
}, 1000);
copyBtn.setAttribute('data-timeout-id', newTimeoutId.toString());
} catch (err) {
console.error(i18nCopyFailed, err);
}
});
frame.appendChild(copyBtn);
wrapper.appendChild(frame);
});
}
// 为标题添加id和锚点链接的函数
function addHeadingAnchors(container) {
const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6');
const usedIds = new Set();
headings.forEach(heading => {
// 生成slug id模拟rehype-slug功能
let slug = heading.textContent
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '') // 移除特殊字符
.replace(/[\s_-]+/g, '-') // 替换空格和下划线为连字符
.replace(/^-+|-+$/g, ''); // 移除开头和结尾的连字符
// 确保id唯一
let uniqueSlug = slug;
let counter = 1;
while (usedIds.has(uniqueSlug)) {
uniqueSlug = `${slug}-${counter}`;
counter++;
}
usedIds.add(uniqueSlug);
// 设置id
heading.id = uniqueSlug;
// 添加锚点链接模拟rehype-autolink-headings功能
const anchor = document.createElement('span');
anchor.className = 'anchor-icon';
anchor.setAttribute('data-pagefind-ignore', 'true');
anchor.textContent = '#';
const anchorWrapper = document.createElement('a');
anchorWrapper.className = 'anchor';
anchorWrapper.href = `#${uniqueSlug}`;
anchorWrapper.appendChild(anchor);
// 将锚点添加到标题末尾
heading.appendChild(anchorWrapper);
});
}
async function renderMarkdownContent(markdownText) {
// 加载marked库用于markdown渲染 - 使用本地文件
if (typeof marked === 'undefined') {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = '/assets/js/marked.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// 配置marked的基本选项
marked.setOptions({
breaks: true,
gfm: true
});
const contentDiv = document.getElementById('decrypted-content');
const htmlContent = marked.parse(markdownText);
// 创建与正常文章完全相同的结构
const markdownWrapper = document.createElement('div');
markdownWrapper.className = 'prose dark:prose-invert prose-base !max-w-none custom-md mb-6 markdown-content onload-animation';
markdownWrapper.setAttribute('data-pagefind-body', '');
markdownWrapper.innerHTML = htmlContent;
// 为标题添加id和锚点链接模拟rehype-slug和rehype-autolink-headings插件功能
addHeadingAnchors(markdownWrapper);
// 为代码块添加样式和功能(异步,使用与全局一致的样式)
await enhanceCodeBlocks(markdownWrapper);
// 清空容器并添加新内容
contentDiv.innerHTML = '';
contentDiv.appendChild(markdownWrapper);
// 重置contentDiv的样式让它作为普通容器
contentDiv.className = '';
contentDiv.style.cssText = '';
// 触发TOC更新
const tocElement = document.querySelector('table-of-contents');
if (tocElement && typeof tocElement.regenerateTOC === 'function') {
// 等待DOM更新后重新生成TOC
setTimeout(() => {
tocElement.regenerateTOC();
tocElement.init();
}, 100);
}
// 触发移动端TOC更新
if (typeof window.mobileTOCInit === 'function') {
setTimeout(() => {
window.mobileTOCInit();
}, 150);
}
// 触发动画结束事件让TOC知道动画完成
const animationEndEvent = new Event('animationend');
markdownWrapper.dispatchEvent(animationEndEvent);
}
async function initPasswordProtection() {
await loadCryptoLibraries();
const passwordInput = document.getElementById('password-input');
const unlockBtn = document.getElementById('unlock-btn');
const errorMessage = document.getElementById('error-message');
const protectionDiv = document.getElementById('password-protection');
const contentDiv = document.getElementById('decrypted-content');
async function attemptUnlock() {
const inputPassword = passwordInput.value.trim();
if (!inputPassword) {
showError(i18nPasswordRequired);
return;
}
unlockBtn.disabled = true;
unlockBtn.textContent = i18nUnlocking;
errorMessage.style.display = 'none';
try {
// 验证密码
const isPasswordCorrect = bcrypt.compareSync(inputPassword, passwordHash);
if (!isPasswordCorrect) {
showError(i18nIncorrect);
return;
}
// 使用密码哈希的前32个字符作为解密密钥与服务端加密逻辑一致
const encryptionKey = passwordHash.substring(0, 32);
// 解密内容
const decryptedBytes = CryptoJS.AES.decrypt(encryptedContent, encryptionKey);
const decryptedContent = decryptedBytes.toString(CryptoJS.enc.Utf8);
if (!decryptedContent) {
showError(i18nDecryptError);
return;
}
// 渲染markdown内容
await renderMarkdownContent(decryptedContent);
protectionDiv.style.display = 'none';
contentDiv.style.display = 'block';
// 保存解锁状态到sessionStorage可选
sessionStorage.setItem('page-unlocked-' + window.location.pathname, 'true');
} catch (error) {
console.error(i18n(I18nKey.decryptionError), error);
showError(i18n(I18nKey.passwordDecryptRetry));
} finally {
unlockBtn.disabled = false;
unlockBtn.textContent = i18nUnlock;
}
}
function showError(message) {
errorMessage.textContent = message;
errorMessage.style.display = 'block';
passwordInput.focus();
}
// 事件监听
unlockBtn.addEventListener('click', attemptUnlock);
passwordInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
attemptUnlock();
}
});
// 检查是否已经解锁过(可选功能)
if (sessionStorage.getItem('page-unlocked-' + window.location.pathname) === 'true') {
passwordInput.value = 'auto-unlock';
// 这里可以添加自动解锁逻辑,但需要存储密码,不太安全
}
// 聚焦到密码输入框
passwordInput.focus();
}
// 页面加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPasswordProtection);
} else {
initPasswordProtection();
}
</script>

View File

@@ -0,0 +1,127 @@
---
import type { CollectionEntry } from "astro:content";
import { render } from "astro:content";
// import * as path from "node:path";
import { Icon } from "astro-icon/components";
import { getFileDirFromPath, getTagUrl } from "@/utils/url-utils";
import { siteConfig } from "../config";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
import ImageWrapper from "./misc/ImageWrapper.astro";
import PostMetadata from "./PostMeta.astro";
interface Props {
class?: string;
entry: CollectionEntry<"posts">;
title: string;
url: string;
published: Date;
updated?: Date;
tags: string[];
category: string | null;
image: string;
description: string;
draft: boolean;
pinned?: boolean;
style: string;
}
const {
entry,
title,
url,
published,
updated,
tags,
category,
image,
description,
pinned,
style,
} = Astro.props;
const className = Astro.props.class;
const hasCover = image !== undefined && image !== null && image !== "";
const coverWidth = "28%";
const { remarkPluginFrontmatter } = await render(entry);
---
<div class:list={["card-base flex flex-col-reverse md:flex-col w-full rounded-[var(--radius-large)] overflow-hidden relative", className]} style={style}>
<div class:list={["pl-6 md:pl-9 pr-6 md:pr-2 pt-6 md:pt-7 pb-6 relative", {"w-full md:w-[calc(100%_-_52px_-_12px)]": !hasCover, "w-full md:w-[calc(100%_-_var(--coverWidth)_-_12px)]": hasCover}]}>
<a href={url}
class="transition group w-full block font-bold mb-3 text-3xl text-90
hover:text-[var(--primary)] dark:hover:text-[var(--primary)]
active:text-[var(--title-active)] dark:active:text-[var(--title-active)]
before:w-1 before:h-5 before:rounded-md before:bg-[var(--primary)]
before:absolute before:top-[35px] before:left-[18px] before:hidden md:before:block
">
{pinned && <Icon name="mdi:pin" class="inline text-[var(--primary)] text-2xl mr-2 -translate-y-0.5"></Icon>}
{title}
<Icon class="inline text-[2rem] text-[var(--primary)] md:hidden translate-y-0.5 absolute" name="material-symbols:chevron-right-rounded" ></Icon>
<Icon class="text-[var(--primary)] text-[2rem] transition hidden md:inline absolute translate-y-0.5 opacity-0 group-hover:opacity-100 -translate-x-1 group-hover:translate-x-0" name="material-symbols:chevron-right-rounded"></Icon>
</a>
<!-- metadata (without tags) -->
<PostMetadata published={published} updated={updated} tags={tags} category={category || ''} hideTagsForMobile={true} hideUpdateDate={true} className="mb-4" showOnlyBasicMeta={true} words={remarkPluginFrontmatter.words} showWordCount={true}></PostMetadata>
<!-- description -->
<div class:list={["transition text-75 mb-3.5 pr-4", {"line-clamp-2 md:line-clamp-1": !description}]}>
{ description || remarkPluginFrontmatter.excerpt }
</div>
<!-- tags (moved to bottom) -->
<div class="flex flex-wrap gap-2 mt-2">
{tags && tags.length > 0 ? (
tags.map((tag) => (
<a
href={getTagUrl(tag)}
class:list={[
siteConfig.tagStyle?.useNewStyle
? "link-lg transition text-50 text-xs font-medium px-2 py-1 rounded-lg hover:text-[var(--primary)] dark:hover:text-[var(--primary)] active:text-[var(--primary)] dark:active:text-[var(--primary)] group/tag whitespace-nowrap"
: "btn-regular h-6 text-xs px-2 rounded-lg"
]}
aria-label={`View all posts tagged with ${tag.trim()}`}
>
<span class="transition-transform group-hover/tag:translate-x-0.5">
# {tag.trim()}
</span>
</a>
))
) : (
<span class="text-xs text-50">{i18n(I18nKey.noTags)}</span>
)}
</div>
</div>
{hasCover && <a href={url} aria-label={title}
class:list={["group",
"max-h-[20vh] md:max-h-none mx-4 mt-4 -mb-2 md:mb-0 md:mx-0 md:mt-0",
"md:w-[var(--coverWidth)] relative md:absolute md:top-3 md:bottom-3 md:right-3 rounded-xl overflow-hidden active:scale-95"
]} >
<div class="absolute pointer-events-none z-10 w-full h-full group-hover:bg-black/30 group-active:bg-black/50 transition"></div>
<div class="absolute pointer-events-none z-20 w-full h-full flex items-center justify-center ">
<Icon name="material-symbols:chevron-right-rounded"
class="transition opacity-0 group-hover:opacity-100 scale-50 group-hover:scale-100 text-white text-5xl">
</Icon>
</div>
<ImageWrapper src={image} basePath={getFileDirFromPath(entry.filePath || '')} alt="Cover Image of the Post"
class="w-full h-full">
</ImageWrapper>
</a>}
{!hasCover &&
<a href={url} aria-label={title} class="!hidden md:!flex btn-regular w-[3.25rem]
absolute right-3 top-3 bottom-3 rounded-xl bg-[var(--enter-btn-bg)]
hover:bg-[var(--enter-btn-bg-hover)] active:bg-[var(--enter-btn-bg-active)] active:scale-95
">
<Icon name="material-symbols:chevron-right-rounded"
class="transition text-[var(--primary)] text-4xl mx-auto">
</Icon>
</a>
}
</div>
<div class="transition border-t-[1px] border-dashed mx-6 border-black/10 dark:border-white/[0.15] last:border-t-0 md:hidden"></div>
<style define:vars={{coverWidth}}>
</style>

View File

@@ -0,0 +1,171 @@
---
import { Icon } from "astro-icon/components";
import { umamiConfig } from "../config";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
import { formatDateToYYYYMMDD } from "../utils/date-utils";
import { getCategoryUrl, getTagUrl } from "../utils/url-utils";
// 解析 umami
const umamiEnabled = umamiConfig.enabled || false;
const umamiWebsiteId =
umamiConfig.scripts.match(/data-website-id="([^"]+)"/)?.[1] || "";
const umamiApiKey = umamiConfig.apiKey || "";
const umamiBaseUrl = umamiConfig.baseUrl || "";
export interface Props {
published: Date;
updated?: Date;
category?: string;
tags?: string[];
hideUpdateDate?: boolean;
hideTagsForMobile?: boolean;
isHome?: boolean;
className?: string;
id?: string;
showOnlyBasicMeta?: boolean; // 新增属性,控制是否只显示基本元数据
words?: number; // 字数统计
minutes?: number; // 阅读时间(分钟)
showWordCount?: boolean; // 是否显示字数统计
}
const {
published,
updated,
category,
tags,
hideUpdateDate,
hideTagsForMobile,
isHome,
className = "",
id,
showOnlyBasicMeta = false, // 默认为false保持原有行为
words,
// minutes,
showWordCount = false, // 默认不显示字数统计
} = Astro.props;
---
<div class:list={["flex flex-wrap text-neutral-500 dark:text-neutral-400 items-center gap-4 gap-x-4 gap-y-2", className]}>
<!-- publish date -->
<div class="flex items-center">
<div class="meta-icon">
<Icon name="material-symbols:calendar-today-outline-rounded" class="text-xl"></Icon>
</div>
<span class="text-50 text-sm font-medium">{formatDateToYYYYMMDD(published)}</span>
</div>
<!-- update date -->
{!hideUpdateDate && updated && updated.getTime() !== published.getTime() && (
<div class="flex items-center">
<div class="meta-icon">
<Icon name="material-symbols:edit-calendar-outline-rounded" class="text-xl"></Icon>
</div>
<span class="text-50 text-sm font-medium">{formatDateToYYYYMMDD(updated)}</span>
</div>
)}
<!-- categories -->
<div class="flex items-center">
<div class="meta-icon">
<Icon name="material-symbols:book-2-outline-rounded" class="text-xl"></Icon>
</div>
<div class="flex flex-row flex-nowrap items-center">
<a href={getCategoryUrl(category || '')} aria-label={`View all posts in the ${category} category`}
class="link-lg transition text-50 text-sm font-medium
hover:text-[var(--primary)] dark:hover:text-[var(--primary)] whitespace-nowrap">
{category || i18n(I18nKey.uncategorized)}
</a>
</div>
</div>
<!-- word count -->
{showWordCount && words && (
<div class="flex items-center">
<div class="meta-icon">
<Icon name="material-symbols:article-outline-rounded" class="text-xl"></Icon>
</div>
<span class="text-50 text-sm font-medium">
{words} {words > 1 ? i18n(I18nKey.wordsCount) : i18n(I18nKey.wordCount)}
</span>
</div>
)}
<!-- tags (只有在不显示基本元数据时才显示) -->
{!showOnlyBasicMeta && (
<div class:list={["items-center", {"flex": !hideTagsForMobile, "hidden md:flex": hideTagsForMobile}]}>
<div class="meta-icon">
<Icon name="material-symbols:tag-rounded" class="text-xl"></Icon>
</div>
<div class="flex flex-row flex-nowrap items-center">
{(tags && tags.length > 0) && tags.map((tag, i) => (
<>
<div class:list={[{"hidden": i == 0}, "mx-1.5 text-[var(--meta-divider)] text-sm"]}>/</div>
<a href={getTagUrl(tag)} aria-label={`View all posts with the ${tag.trim()} tag`}
class="link-lg transition text-50 text-sm font-medium
hover:text-[var(--primary)] dark:hover:text-[var(--primary)] whitespace-nowrap">
{tag.trim()}
</a>
</>
))}
{!(tags && tags.length > 0) && <div class="transition text-50 text-sm font-medium">{i18n(I18nKey.noTags)}</div>}
</div>
</div>
)}
<!-- 访问量首页不显示且umami.enabled为true时显示 -->
{!isHome && umamiEnabled && id && (
<div class="flex items-center">
<div class="meta-icon">
<Icon name="material-symbols:visibility-outline-rounded" class="text-xl"></Icon>
</div>
<span class="text-50 text-sm font-medium" id="page-views-display">统计加载中...</span>
</div>
)}
</div>
<!-- 只有在非首页且启用umami且有slug时才加载脚本 -->
{!isHome && umamiEnabled && id && (
<script is:inline define:vars={{ id, umamiBaseUrl, umamiApiKey, umamiWebsiteId, umamiConfig }}>
// 客户端统计文案生成函数
function generateStatsText(pageViews, visitors) {
return `浏览量 ${pageViews} · 访客 ${visitors}`;
}
// 获取访问量统计
async function fetchPageViews(isRetry = false) {
// @ts-ignore
if (!umamiConfig.enabled || !umamiWebsiteId || !id || !isRetry) return;
try {
// 构造文章页面的URL路径
const pageUrl = `/posts/${id}/`;
// 调用全局工具获取特定页面的 Umami 统计数据
const stats = await getUmamiPageStats(umamiBaseUrl, umamiApiKey, umamiWebsiteId, pageUrl);
// 从返回的数据中提取页面浏览量和访客数
const pageViews = stats.pageviews || 0;
const visitors = stats.visitors || 0;
const displayElement = document.getElementById('page-views-display');
if (displayElement) {
displayElement.textContent = generateStatsText(pageViews, visitors);
}
} catch (error) {
console.error('Error fetching page views:', error);
const displayElement = document.getElementById('page-views-display');
if (displayElement) {
displayElement.textContent = '统计不可用';
}
}
}
// 页面加载完成后获取统计数据
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fetchPageViews);
} else {
fetchPageViews();
}
</script>
)}

View File

@@ -0,0 +1,370 @@
---
import type { CollectionEntry } from "astro:content";
import { getPostUrl } from "@utils/url-utils";
import { sidebarLayoutConfig, siteConfig } from "../config";
import PostCard from "./PostCard.astro";
const { page } = Astro.props;
let delay = 0;
const interval = 50;
// 检查是否启用双侧边栏
const isBothSidebars = sidebarLayoutConfig.position === "both";
// 根据配置设置初始布局模式,避免闪烁
const defaultLayout = siteConfig.postListLayout.defaultMode || "list";
const initialLayoutClass =
defaultLayout === "grid"
? "grid grid-cols-1 md:grid-cols-2 gap-2 md:gap-4 grid-mode"
: "flex flex-col gap-2 md:gap-4 list-mode";
---
<div
id="post-list-container"
class={`transition-all duration-500 ease-in-out rounded-[var(--radius-large)] bg-[var(--card-bg)] py-0 md:py-0 md:bg-transparent mb-4 ${initialLayoutClass}`}
data-default-layout={defaultLayout}
data-both-sidebars={isBothSidebars}
>
{page.data.map((entry: CollectionEntry<"posts">) => (
<PostCard
entry={entry}
title={entry.data.title}
tags={entry.data.tags}
category={entry.data.category}
published={entry.data.published}
updated={entry.data.updated}
url={getPostUrl(entry)}
image={entry.data.image}
description={entry.data.description}
draft={entry.data.draft}
pinned={entry.data.pinned}
class:list="onload-animation"
style={`animation-delay: calc(var(--content-delay) + ${delay++ * interval}ms);`}
></PostCard>
))}
</div>
<script>
// 动态布局切换脚本
function initLayout() {
// 延迟执行以确保DOM已完全加载
setTimeout(() => {
const postListContainer = document.getElementById("post-list-container");
if (!postListContainer) {
// 如果找不到容器,说明当前页面不是文章列表页面
console.debug("post-list-container not found, skipping layout initialization (not a post list page)");
// setTimeout(initLayout, 100);
return;
}
// 检查是否启用双侧边栏
const isBothSidebars = postListContainer.getAttribute("data-both-sidebars") === "true";
// 从localStorage读取用户偏好
const savedLayout = localStorage.getItem("postListLayout");
const defaultLayout = postListContainer.getAttribute("data-default-layout") || "list";
let currentLayout = savedLayout || defaultLayout;
console.debug("Initializing layout:", { savedLayout, defaultLayout, currentLayout, isBothSidebars });
// 检查main-grid是否已经设置了正确的layout-mode避免重复操作导致闪烁
const mainGrid = document.getElementById('main-grid');
if (mainGrid) {
const existingMode = mainGrid.getAttribute('data-layout-mode');
if (existingMode === currentLayout) {
console.debug("Layout already correct, skipping update to avoid flicker");
postListContainer.classList.add("js-initialized");
publishLayoutInit();
return;
}
}
// 移动端由CSS处理桌面端才需要JavaScript初始化
const isDesktop = window.innerWidth >= 769;
if (isDesktop) {
updatePostListLayout(currentLayout);
} else {
// 移动端只添加初始化标记不更改布局CSS已处理
postListContainer.classList.add("js-initialized");
}
// 发布布局初始化事件
publishLayoutInit();
}, 50);
}
function updatePostListLayout(layout: string) {
const postListContainer = document.getElementById("post-list-container");
if (!postListContainer) return;
// 添加切换动画类
postListContainer.classList.add("layout-switching");
// 移除现有布局类
postListContainer.classList.remove("list-mode", "grid-mode");
// 添加新布局类
if (layout === "grid") {
postListContainer.classList.add("grid-mode");
// 网格模式:双列布局
postListContainer.classList.add("grid", "grid-cols-1", "md:grid-cols-2", "gap-6");
postListContainer.classList.remove("flex", "flex-col");
// 在 grid 模式下隐藏右侧边栏
const rightSidebar = document.querySelector('.right-sidebar-container');
if (rightSidebar) {
(rightSidebar as HTMLElement).classList.add('hidden-in-grid-mode');
}
// 调整主网格布局以使用更多空间
const mainGrid = document.getElementById('main-grid');
if (mainGrid) {
mainGrid.setAttribute('data-layout-mode', 'grid');
}
} else {
postListContainer.classList.add("list-mode");
// 列表模式:单列布局
postListContainer.classList.add("flex", "flex-col");
postListContainer.classList.remove("grid", "grid-cols-1", "md:grid-cols-2", "gap-6");
// 在 list 模式下显示右侧边栏
const rightSidebar = document.querySelector('.right-sidebar-container');
if (rightSidebar) {
(rightSidebar as HTMLElement).classList.remove('hidden-in-grid-mode');
}
// 恢复主网格布局
const mainGrid = document.getElementById('main-grid');
if (mainGrid) {
mainGrid.setAttribute('data-layout-mode', 'list');
}
}
// 添加初始化完成标记,用于移除闪烁保护
postListContainer.classList.add("js-initialized");
// 移除切换动画类
setTimeout(() => {
postListContainer.classList.remove("layout-switching");
}, 500);
}
// 监听布局变化事件
window.addEventListener("layoutChange", (event: any) => {
const postListContainer = document.getElementById("post-list-container");
if (!postListContainer) return;
// 直接应用用户选择的布局
updatePostListLayout(event.detail.layout);
});
// 监听窗口大小变化调整布局CSS已处理移动端这里只处理桌面端切换
window.addEventListener("resize", () => {
const isDesktop = window.innerWidth >= 769;
const savedLayout = localStorage.getItem("postListLayout");
const defaultLayout = document.getElementById("post-list-container")?.getAttribute("data-default-layout") || "list";
const currentLayout = savedLayout || defaultLayout;
if (isDesktop) {
updatePostListLayout(currentLayout);
}
// 移动端由CSS处理不需要JavaScript干预
});
// 页面加载时初始化布局
document.addEventListener("DOMContentLoaded", initLayout);
// 如果页面已经加载完成,直接初始化
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initLayout);
} else {
initLayout();
}
// 监听Swup页面切换事件
function setupSwupListeners() {
function trySetupSwup() {
if (typeof window !== "undefined" && (window as any).swup) {
const swup = (window as any).swup;
// 页面内容替换后重新初始化布局
swup.hooks.on("content:replace", () => {
setTimeout(() => {
console.debug("Swup content:replace - reinitializing layout");
initLayout();
}, 100);
});
// 页面视图切换后重新初始化布局
swup.hooks.on("page:view", () => {
setTimeout(() => {
console.debug("Swup page:view - reinitializing layout");
initLayout();
}, 100);
});
// 动画进入结束后重新初始化布局
swup.hooks.on("animation:in:end", () => {
setTimeout(() => {
console.debug("Swup animation:in:end - reinitializing layout");
initLayout();
}, 50);
});
console.debug("Swup layout listeners registered");
return true;
}
return false;
}
// 尝试立即设置Swup监听器
if (!trySetupSwup()) {
// 如果Swup尚未初始化延迟重试
let retryCount = 0;
const maxRetries = 10;
const retryInterval = setInterval(() => {
if (trySetupSwup()) {
clearInterval(retryInterval);
} else if (++retryCount >= maxRetries) {
clearInterval(retryInterval);
// 降级处理:监听普通页面切换事件
window.addEventListener("popstate", () => {
setTimeout(() => {
console.debug("Popstate - reinitializing layout");
initLayout();
}, 100);
});
// 监听路由变化适用于Astro的客户端导航
if (window.location) {
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function(...args) {
originalPushState.apply(this, args);
setTimeout(() => {
console.debug("PushState - reinitializing layout");
initLayout();
}, 100);
};
history.replaceState = function(...args) {
originalReplaceState.apply(this, args);
setTimeout(() => {
console.debug("ReplaceState - reinitializing layout");
initLayout();
}, 100);
};
}
console.debug("Fallback layout listeners registered");
}
}, 100);
}
}
// 延迟设置Swup监听器确保Swup已初始化
setTimeout(setupSwupListeners, 200);
// 发布布局初始化完成事件通知LayoutSwitchButton
function publishLayoutInit() {
const postListContainer = document.getElementById("post-list-container");
if (postListContainer) {
const isGridMode = postListContainer.classList.contains("grid-mode");
const currentLayout = isGridMode ? "grid" : "list";
const event = new CustomEvent("layoutInit", {
detail: { layout: currentLayout }
});
window.dispatchEvent(event);
}
}
// 在布局初始化后发布事件
setTimeout(publishLayoutInit, 150);
</script>
<style>
/* 布局切换动画 */
#post-list-container {
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 文章卡片的过渡动画 */
#post-list-container > :global(*) {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 布局切换时的动画效果 */
#post-list-container.layout-switching {
opacity: 0.95;
}
#post-list-container.layout-switching > :global(*) {
transform: scale(0.98);
}
/* 网格模式的特殊动画 */
#post-list-container.grid-mode > :global(*) {
animation: fadeInScale 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
/* 列表模式的特殊动画 */
#post-list-container.list-mode > :global(*) {
animation: fadeInSlide 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes fadeInSlide {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* 为每个卡片添加延迟动画 */
#post-list-container.layout-switching > :global(*:nth-child(1)) {
animation-delay: 0ms;
}
#post-list-container.layout-switching > :global(*:nth-child(2)) {
animation-delay: 50ms;
}
#post-list-container.layout-switching > :global(*:nth-child(3)) {
animation-delay: 100ms;
}
#post-list-container.layout-switching > :global(*:nth-child(4)) {
animation-delay: 150ms;
}
#post-list-container.layout-switching > :global(*:nth-child(5)) {
animation-delay: 200ms;
}
#post-list-container.layout-switching > :global(*:nth-child(6)) {
animation-delay: 250ms;
}
#post-list-container.layout-switching > :global(*:nth-child(7)) {
animation-delay: 300ms;
}
#post-list-container.layout-switching > :global(*:nth-child(8)) {
animation-delay: 350ms;
}
#post-list-container.layout-switching > :global(*:nth-child(9)) {
animation-delay: 400ms;
}
#post-list-container.layout-switching > :global(*:nth-child(10)) {
animation-delay: 450ms;
}
</style>

View File

@@ -0,0 +1,211 @@
<script lang="ts">
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import Icon from "@iconify/svelte";
import { navigateToPage } from "@utils/navigation-utils";
import { url } from "@utils/url-utils.ts";
import { onMount } from "svelte";
import type { SearchResult } from "@/global";
import { panelManager } from "../utils/panel-manager.js";
let keywordDesktop = "";
let keywordMobile = "";
let result: SearchResult[] = [];
let isSearching = false;
let pagefindLoaded = false;
let initialized = false;
const fakeResult: SearchResult[] = [
{
url: url("/"),
meta: {
title: "This Is a Fake Search Result",
},
excerpt:
"Because the search cannot work in the <mark>dev</mark> environment.",
},
{
url: url("/"),
meta: {
title: "If You Want to Test the Search",
},
excerpt: "Try running <mark>npm build && npm preview</mark> instead.",
},
];
const togglePanel = async () => {
await panelManager.togglePanel("search-panel");
};
const setPanelVisibility = async (
show: boolean,
isDesktop: boolean,
): Promise<void> => {
if (!isDesktop) return;
await panelManager.togglePanel("search-panel", show);
};
const closeSearchPanel = async (): Promise<void> => {
await panelManager.closePanel("search-panel");
// 清空搜索关键词和结果
keywordDesktop = "";
keywordMobile = "";
result = [];
};
const handleResultClick = (event: Event, url: string): void => {
event.preventDefault();
closeSearchPanel();
navigateToPage(url);
};
const search = async (keyword: string, isDesktop: boolean): Promise<void> => {
if (!keyword) {
setPanelVisibility(false, isDesktop);
result = [];
return;
}
if (!initialized) {
return;
}
isSearching = true;
try {
let searchResults: SearchResult[] = [];
if (import.meta.env.PROD && pagefindLoaded && window.pagefind) {
const response = await window.pagefind.search(keyword);
searchResults = await Promise.all(
response.results.map((item) => item.data()),
);
} else if (import.meta.env.DEV) {
searchResults = fakeResult;
} else {
searchResults = [];
console.error("Pagefind is not available in production environment.");
}
result = searchResults;
setPanelVisibility(result.length > 0, isDesktop);
} catch (error) {
console.error("Search error:", error);
result = [];
setPanelVisibility(false, isDesktop);
} finally {
isSearching = false;
}
};
onMount(() => {
const initializeSearch = () => {
initialized = true;
pagefindLoaded =
typeof window !== "undefined" &&
!!window.pagefind &&
typeof window.pagefind.search === "function";
console.log("Pagefind status on init:", pagefindLoaded);
if (keywordDesktop) search(keywordDesktop, true);
if (keywordMobile) search(keywordMobile, false);
};
if (import.meta.env.DEV) {
console.log(
"Pagefind is not available in development mode. Using mock data.",
);
initializeSearch();
} else {
document.addEventListener("pagefindready", () => {
console.log("Pagefind ready event received.");
initializeSearch();
});
document.addEventListener("pagefindloaderror", () => {
console.warn(
"Pagefind load error event received. Search functionality will be limited.",
);
initializeSearch(); // Initialize with pagefindLoaded as false
});
// Fallback in case events are not caught or pagefind is already loaded by the time this script runs
setTimeout(() => {
if (!initialized) {
console.log("Fallback: Initializing search after timeout.");
initializeSearch();
}
}, 2000); // Adjust timeout as needed
}
});
$: if (initialized && keywordDesktop) {
(async () => {
await search(keywordDesktop, true);
})();
}
$: if (initialized && keywordMobile) {
(async () => {
await search(keywordMobile, false);
})();
}
</script>
<!-- search bar for desktop view -->
<div id="search-bar" class="hidden lg:flex transition-all items-center h-11 mr-2 rounded-lg
bg-black/[0.04] hover:bg-black/[0.06] focus-within:bg-black/[0.06]
dark:bg-white/5 dark:hover:bg-white/10 dark:focus-within:bg-white/10
">
<Icon icon="material-symbols:search" class="absolute text-[1.25rem] pointer-events-none ml-3 transition my-auto text-black/30 dark:text-white/30"></Icon>
<input placeholder="{i18n(I18nKey.search)}" bind:value={keywordDesktop} on:focus={() => search(keywordDesktop, true)}
class="transition-all pl-10 text-sm bg-transparent outline-0
h-full w-40 active:w-60 focus:w-60 text-black/50 dark:text-white/50"
>
</div>
<!-- toggle btn for phone/tablet view -->
<button on:click={togglePanel} aria-label="Search Panel" id="search-switch"
class="btn-plain scale-animation lg:!hidden rounded-lg w-11 h-11 active:scale-90">
<Icon icon="material-symbols:search" class="text-[1.25rem]"></Icon>
</button>
<!-- search panel -->
<div id="search-panel" class="float-panel float-panel-closed search-panel absolute md:w-[30rem]
top-20 left-4 md:left-[unset] right-4 shadow-2xl rounded-2xl p-2">
<!-- search bar inside panel for phone/tablet -->
<div id="search-bar-inside" class="flex relative lg:hidden transition-all items-center h-11 rounded-xl
bg-black/[0.04] hover:bg-black/[0.06] focus-within:bg-black/[0.06]
dark:bg-white/5 dark:hover:bg-white/10 dark:focus-within:bg-white/10
">
<Icon icon="material-symbols:search" class="absolute text-[1.25rem] pointer-events-none ml-3 transition my-auto text-black/30 dark:text-white/30"></Icon>
<input placeholder="Search" bind:value={keywordMobile}
class="pl-10 absolute inset-0 text-sm bg-transparent outline-0
focus:w-60 text-black/50 dark:text-white/50"
>
</div>
<!-- search results -->
{#each result as item}
<a href={item.url}
on:click={(e) => handleResultClick(e, item.url)}
class="transition first-of-type:mt-2 lg:first-of-type:mt-0 group block
rounded-xl text-lg px-3 py-2 hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)]">
<div class="transition text-90 inline-flex font-bold group-hover:text-[var(--primary)]">
{item.meta.title}<Icon icon="fa6-solid:chevron-right" class="transition text-[0.75rem] translate-x-1 my-auto text-[var(--primary)]"></Icon>
</div>
<div class="transition text-sm text-50">
{@html item.excerpt}
</div>
</a>
{/each}
</div>
<style>
input:focus {
outline: 0;
}
.search-panel {
max-height: calc(100vh - 100px);
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,152 @@
---
export interface Props {
text: string | string[];
speed?: number;
deleteSpeed?: number;
pauseTime?: number;
class?: string;
}
const {
text,
speed = 100,
deleteSpeed = 50,
pauseTime = 2000,
class: className = "",
} = Astro.props;
const textData = Array.isArray(text) ? JSON.stringify(text) : text;
---
<span class={`typewriter ${className}`} data-text={textData} data-speed={speed} data-delete-speed={deleteSpeed} data-pause-time={pauseTime}></span>
<script>
class TypewriterEffect {
private element: HTMLElement;
private texts: string[];
private currentTextIndex: number = 0;
private speed: number;
private deleteSpeed: number;
private pauseTime: number;
private currentIndex: number = 0;
private isDeleting: boolean = false;
private timeoutId: number | null = null;
constructor(element: HTMLElement) {
this.element = element;
const textData = element.dataset.text || '';
// 尝试解析为JSON数组如果失败则作为单个字符串处理
try {
const parsed = JSON.parse(textData);
this.texts = Array.isArray(parsed) ? parsed : [textData];
} catch {
this.texts = [textData];
}
this.speed = parseInt(element.dataset.speed || '100');
this.deleteSpeed = parseInt(element.dataset.deleteSpeed || '50');
this.pauseTime = parseInt(element.dataset.pauseTime || '2000');
// 如果有多条文本且未启用打字机效果,随机显示一条
if (this.texts.length > 1 && !this.isTypewriterEnabled()) {
this.showRandomText();
} else {
this.start();
}
}
private isTypewriterEnabled(): boolean {
// 检查是否有打字机相关的数据属性
return this.element.dataset.speed !== undefined ||
this.element.dataset.deleteSpeed !== undefined ||
this.element.dataset.pauseTime !== undefined;
}
private showRandomText() {
const randomIndex = Math.floor(Math.random() * this.texts.length);
this.element.textContent = this.texts[randomIndex];
}
private start() {
if (this.texts.length === 0) return;
this.type();
}
private getCurrentText(): string {
return this.texts[this.currentTextIndex] || '';
}
private type() {
const currentText = this.getCurrentText();
if (this.isDeleting) {
// 删除字符
if (this.currentIndex > 0) {
this.currentIndex--;
this.element.textContent = currentText.substring(0, this.currentIndex);
this.timeoutId = window.setTimeout(() => this.type(), this.deleteSpeed);
} else {
// 删除完成,切换到下一条文本
this.isDeleting = false;
this.currentTextIndex = (this.currentTextIndex + 1) % this.texts.length;
this.timeoutId = window.setTimeout(() => this.type(), this.speed);
}
} else {
// 添加字符
if (this.currentIndex < currentText.length) {
this.currentIndex++;
this.element.textContent = currentText.substring(0, this.currentIndex);
this.timeoutId = window.setTimeout(() => this.type(), this.speed);
} else {
// 打字完成,暂停后开始删除(如果有多条文本)
if (this.texts.length > 1) {
this.isDeleting = true;
this.timeoutId = window.setTimeout(() => this.type(), this.pauseTime);
} else {
// 如果只有一条文本,保持显示不删除,但确保元素有内容
if (this.texts.length === 1 && this.texts[0] === '') {
this.element.innerHTML = '&nbsp;'; // 使用不间断空格保持布局
}
}
}
}
}
public destroy() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
}
}
// 初始化所有打字机效果
document.addEventListener('DOMContentLoaded', () => {
const typewriterElements = document.querySelectorAll('.typewriter');
typewriterElements.forEach((element) => {
new TypewriterEffect(element as HTMLElement);
});
});
// 支持页面切换时重新初始化
document.addEventListener('swup:contentReplaced', () => {
const typewriterElements = document.querySelectorAll('.typewriter');
typewriterElements.forEach((element) => {
new TypewriterEffect(element as HTMLElement);
});
});
</script>
<style>
.typewriter {
position: relative;
min-height: 1.5em; /* 确保即使没有文字也保持最小高度 */
display: inline-block;
vertical-align: baseline;
}
/* 确保打字机效果不会导致布局抖动 */
.typewriter:empty::before {
content: '\00A0'; /* 使用不间断空格保持高度 */
visibility: hidden;
}
</style>

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import {
WALLPAPER_BANNER,
WALLPAPER_FULLSCREEN,
WALLPAPER_NONE,
} from "@constants/constants.ts";
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import Icon from "@iconify/svelte";
import {
getStoredWallpaperMode,
setWallpaperMode,
} from "@utils/setting-utils.ts";
import type { WALLPAPER_MODE } from "@/types/config.ts";
import { panelManager } from "../utils/panel-manager.js";
const seq: WALLPAPER_MODE[] = [
WALLPAPER_BANNER,
WALLPAPER_FULLSCREEN,
WALLPAPER_NONE,
];
let mode: WALLPAPER_MODE = $state(getStoredWallpaperMode());
function switchWallpaperMode(newMode: WALLPAPER_MODE) {
mode = newMode;
setWallpaperMode(newMode);
}
function toggleWallpaperMode() {
let i = 0;
for (; i < seq.length; i++) {
if (seq[i] === mode) {
break;
}
}
switchWallpaperMode(seq[(i + 1) % seq.length]);
}
async function togglePanel() {
// 关闭其他面板,但保留壁纸面板本身
await panelManager.closeAllPanelsExcept("wallpaper-mode-panel");
await panelManager.togglePanel("wallpaper-mode-panel");
}
</script>
<style>
.current-theme-btn {
background-color: var(--primary);
color: white;
}
/* 确保主题切换按钮的背景色即时更新 */
:global(.theme-switch-btn)::before {
transition: transform 75ms ease-out, background-color 0ms !important;
}
</style>
<!-- z-50 make the panel higher than other float panels -->
<div class="relative z-50" role="menu" tabindex="-1">
<button aria-label="Wallpaper Mode" role="menuitem" class="relative btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90 theme-switch-btn" id="wallpaper-mode-switch" onclick={togglePanel}>
<div class="absolute" class:opacity-0={mode !== WALLPAPER_BANNER}>
<Icon icon="material-symbols:image-outline" class="text-[1.25rem]"></Icon>
</div>
<div class="absolute" class:opacity-0={mode !== WALLPAPER_FULLSCREEN}>
<Icon icon="material-symbols:wallpaper" class="text-[1.25rem]"></Icon>
</div>
<div class="absolute" class:opacity-0={mode !== WALLPAPER_NONE}>
<Icon icon="material-symbols:hide-image-outline" class="text-[1.25rem]"></Icon>
</div>
</button>
<div id="wallpaper-mode-panel" class="absolute transition float-panel-closed top-11 -right-2 pt-5" >
<div class="card-base float-panel p-2">
<button class="flex transition whitespace-nowrap items-center !justify-start w-full btn-plain scale-animation rounded-lg h-9 px-3 font-medium active:scale-95 mb-0.5 theme-switch-btn"
class:current-theme-btn={mode === WALLPAPER_BANNER}
onclick={() => switchWallpaperMode(WALLPAPER_BANNER)}
>
<Icon icon="material-symbols:image-outline" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.wallpaperBanner)}
</button>
<button class="flex transition whitespace-nowrap items-center !justify-start w-full btn-plain scale-animation rounded-lg h-9 px-3 font-medium active:scale-95 mb-0.5 theme-switch-btn"
class:current-theme-btn={mode === WALLPAPER_FULLSCREEN}
onclick={() => switchWallpaperMode(WALLPAPER_FULLSCREEN)}
>
<Icon icon="material-symbols:wallpaper" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.wallpaperFullscreen)}
</button>
<button class="flex transition whitespace-nowrap items-center !justify-start w-full btn-plain scale-animation rounded-lg h-9 px-3 font-medium active:scale-95 theme-switch-btn"
class:current-theme-btn={mode === WALLPAPER_NONE}
onclick={() => switchWallpaperMode(WALLPAPER_NONE)}
>
<Icon icon="material-symbols:hide-image-outline" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.wallpaperNone)}
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,82 @@
---
import { commentConfig } from "@/config";
interface Props {
path: string;
}
const config = {
...commentConfig.twikoo,
el: "#tcomment",
path: Astro.props.path,
};
---
<div id="tcomment"></div>
<script is:inline src="/scroll-protection.js"></script>
<script is:inline src="/assets/js/twikoo.all.min.js"></script>
<script is:inline define:vars={{ config }}>
// 获取当前页面路径
function getCurrentPath() {
const pathname = window.location.pathname;
return pathname.endsWith('/') && pathname.length > 1 ? pathname.slice(0, -1) : pathname;
}
// 动态创建配置对象
function createTwikooConfig() {
return {
...config,
path: getCurrentPath(),
el: '#tcomment'
};
}
// 初始化 Twikoo
function initTwikoo() {
if (typeof twikoo !== 'undefined') {
const commentEl = document.getElementById('tcomment');
if (commentEl) {
commentEl.innerHTML = '';
const dynamicConfig = createTwikooConfig();
console.log('[Twikoo] 初始化配置:', dynamicConfig);
twikoo.init(dynamicConfig).then(() => {
console.log('[Twikoo] 初始化完成');
}).catch((error) => {
console.error('[Twikoo] 初始化失败:', error);
});
}
} else {
// 如果 Twikoo 未加载,稍后重试
setTimeout(initTwikoo, 500);
}
}
// 页面加载时初始化
document.addEventListener('DOMContentLoaded', initTwikoo);
// Swup 页面切换后重新初始化
if (window.swup && window.swup.hooks) {
window.swup.hooks.on('content:replace', function() {
setTimeout(initTwikoo, 200);
});
} else {
document.addEventListener('swup:enable', function() {
if (window.swup && window.swup.hooks) {
window.swup.hooks.on('content:replace', function() {
setTimeout(initTwikoo, 200);
});
}
});
}
// 自定义事件监听
document.addEventListener('mizuki:page:loaded', function() {
const commentEl = document.getElementById('tcomment');
if (commentEl) {
console.log('[Twikoo] 通过自定义事件重新初始化');
initTwikoo();
}
});
</script>

View File

@@ -0,0 +1,27 @@
---
import type { CollectionEntry } from "astro:content";
import { commentConfig } from "@/config";
import { removeFileExtension } from "@/utils/url-utils";
import Twikoo from "./Twikoo.astro";
interface Props {
post: CollectionEntry<"posts">;
}
const { id, data: _data } = Astro.props.post;
const slug = removeFileExtension(id);
const path = `/posts/${slug}`;
// const url = `${Astro.site?.href}${path}`;
let commentService = "";
if (commentConfig?.enable && commentConfig?.twikoo) {
commentService = "twikoo";
}
---
{commentConfig?.enable && (
<div class="card-base p-6 mb-4">
{commentService === 'twikoo' && <Twikoo path={path} />}
{commentService === '' && null}
</div>
)}

View File

@@ -0,0 +1,67 @@
---
import { Icon } from "astro-icon/components";
---
<!-- There can't be a filter on parent element, or it will break `fixed` -->
<div class="back-to-top-wrapper block">
<div id="back-to-top-btn" class="back-to-top-btn hide flex items-center rounded-2xl overflow-hidden transition"
onclick="backToTop()">
<button aria-label="Back to Top" class="btn-card h-[3.75rem] w-[3.75rem]">
<Icon name="material-symbols:keyboard-arrow-up-rounded" class="mx-auto"></Icon>
</button>
</div>
</div>
<style lang="stylus">
.back-to-top-wrapper
width: 3.75rem
height: 3.75rem
position: absolute
right: 0
top: 0
pointer-events: none
.back-to-top-btn
color: var(--primary)
font-size: 2.25rem
font-weight: bold
border: none
position: fixed
bottom: 10rem
opacity: 1
right: 6rem
cursor: pointer
transform: translateX(5rem)
pointer-events: auto
transition: box-shadow 1s ease-in-out
i
font-size: 1.75rem
&.hide
transform: translateX(5rem) scale(0.9)
opacity: 0
pointer-events: none
&:active
transform: translateX(5rem) scale(0.9)
@media (max-width: 1560px)
.back-to-top-btn
box-shadow:
0 0 0 1px var(--btn-regular-bg) ,
0 0 1em var(--btn-regular-bg) ;
// 手机端隐藏返回顶部按钮
@media (max-width: 768px)
.back-to-top-wrapper
display: none
</style>
<script is:raw is:inline>
function backToTop() {
// 直接使用原生滚动避免OverlayScrollbars冲突
window.scroll({top: 0, behavior: 'smooth'});
}
</script>

View File

@@ -0,0 +1,43 @@
---
interface Props {
badge?: string;
url?: string;
label?: string;
}
const { badge, url, label } = Astro.props;
---
<a href={url} aria-label={label}>
<button
class:list={`
w-full
h-10
rounded-lg
bg-none
hover:bg-[var(--btn-plain-bg-hover)]
active:bg-[var(--btn-plain-bg-active)]
transition-all
pl-2
hover:pl-3
text-neutral-700
hover:text-[var(--primary)]
dark:text-neutral-300
dark:hover:text-[var(--primary)]
`
}
>
<div class="flex items-center justify-between relative mr-2">
<div class="overflow-hidden text-left whitespace-nowrap overflow-ellipsis ">
<slot></slot>
</div>
{ badge !== undefined && badge !== null && badge !== '' &&
<div class="transition px-2 h-7 ml-4 min-w-[2rem] rounded-lg text-sm font-bold
text-[var(--btn-content)] dark:text-[var(--deep-text)]
bg-[oklch(0.95_0.025_var(--hue))] dark:bg-[var(--primary)]
flex items-center justify-center">
{ badge }
</div>
}
</div>
</button>
</a>

View File

@@ -0,0 +1,13 @@
---
interface Props {
size?: string;
dot?: boolean;
href?: string;
label?: string;
}
const { dot, href, label }: Props = Astro.props;
---
<a href={href} aria-label={label} class="btn-regular h-8 text-sm px-3 rounded-lg">
{dot && <div class="h-1 w-1 bg-[var(--btn-content)] dark:bg-[var(--card-bg)] transition rounded-md mr-2"></div>}
<slot></slot>
</a>

View File

@@ -0,0 +1,84 @@
---
import type { Page } from "astro";
import { Icon } from "astro-icon/components";
import { url } from "../../utils/url-utils";
interface Props {
page: Page;
class?: string;
style?: string;
}
const { page, style } = Astro.props;
const HIDDEN = -1;
const className = Astro.props.class;
const ADJ_DIST = 2;
const VISIBLE = ADJ_DIST * 2 + 1;
// for test
let count = 1;
let l = page.currentPage;
let r = page.currentPage;
while (0 < l - 1 && r + 1 <= page.lastPage && count + 2 <= VISIBLE) {
count += 2;
l--;
r++;
}
while (0 < l - 1 && count < VISIBLE) {
count++;
l--;
}
while (r + 1 <= page.lastPage && count < VISIBLE) {
count++;
r++;
}
let pages: number[] = [];
if (l > 1) pages.push(1);
if (l === 3) pages.push(2);
if (l > 3) pages.push(HIDDEN);
for (let i = l; i <= r; i++) pages.push(i);
if (r < page.lastPage - 2) pages.push(HIDDEN);
if (r === page.lastPage - 2) pages.push(page.lastPage - 1);
if (r < page.lastPage) pages.push(page.lastPage);
const getPageUrl = (p: number) => {
if (p === 1) return "/";
return `/${p}/`;
};
---
<div class:list={[className, "flex flex-row gap-3 justify-center"]} style={style}>
<a href={page.url.prev || ""} aria-label={page.url.prev ? "Previous Page" : null}
class:list={["btn-card overflow-hidden rounded-lg text-[var(--primary)] w-11 h-11",
{"disabled": page.url.prev == undefined}
]}
>
<Icon name="material-symbols:chevron-left-rounded" class="text-[1.75rem]"></Icon>
</a>
<div class="bg-[var(--card-bg)] flex flex-row rounded-lg items-center text-neutral-700 dark:text-neutral-300 font-bold">
{pages.map((p) => {
if (p == HIDDEN)
return <Icon name="material-symbols:more-horiz" class="mx-1"/>;
if (p == page.currentPage)
return <div class="h-11 w-11 rounded-lg bg-[var(--primary)] flex items-center justify-center
font-bold text-white dark:text-black/70"
>
{p}
</div>
return <a href={url(getPageUrl(p))} aria-label={`Page ${p}`}
class="btn-card w-11 h-11 rounded-lg overflow-hidden active:scale-[0.85]"
>{p}</a>
})}
</div>
<a href={page.url.next || ""} aria-label={page.url.next ? "Next Page" : null}
class:list={["btn-card overflow-hidden rounded-lg text-[var(--primary)] w-11 h-11",
{"disabled": page.url.next == undefined}
]}
>
<Icon name="material-symbols:chevron-right-rounded" class="text-[1.75rem]"></Icon>
</a>
</div>

View File

@@ -0,0 +1,172 @@
---
import type { MarkdownHeading } from "astro";
import Announcement from "@/components/widget/Announcement.astro";
import Calendar from "@/components/widget/Calendar.astro";
import Categories from "@/components/widget/Categories.astro";
import MusicPlayer from "@/components/widget/MusicPlayer.svelte";
import Profile from "@/components/widget/Profile.astro";
import SiteStats from "@/components/widget/SiteStats.astro";
import Tags from "@/components/widget/Tags.astro";
import TOC from "@/components/widget/TOC.astro";
import { widgetManager } from "@/utils/widget-manager";
interface Props {
class?: string;
headings?: MarkdownHeading[];
}
const { class: className, headings } = Astro.props;
// 获取右侧边栏的组件列表
const topComponents = widgetManager.getComponentsByPosition("top", "right");
const stickyComponents = widgetManager.getComponentsByPosition(
"sticky",
"right",
);
// 组件映射表
const componentMap = {
profile: Profile,
announcement: Announcement,
categories: Categories,
tags: Tags,
toc: TOC,
"music-player": MusicPlayer,
"site-stats": SiteStats,
calendar: Calendar,
};
// 渲染组件的辅助函数
function renderComponent(component: any, index: number, _components: any[]) {
const ComponentToRender =
componentMap[component.type as keyof typeof componentMap];
if (!ComponentToRender) return null;
const componentClass = widgetManager.getComponentClass(component, index);
const componentStyle = widgetManager.getComponentStyle(component, index);
return {
Component: ComponentToRender,
props: {
class: componentClass,
style: componentStyle,
headings: component.type === "toc" ? headings : undefined,
...component.customProps,
},
};
}
---
<div id="right-sidebar" class:list={[className, "w-full"]}>
<!-- 顶部固定组件区域 -->
{
topComponents.length > 0 && (
<div class="flex flex-col w-full gap-4 mb-4">
{topComponents.map((component, index) => {
const renderData = renderComponent(component, index, topComponents);
if (!renderData) return null;
const { Component, props } = renderData;
return <Component {...props} />;
})}
</div>
)
}
<!-- 粘性组件区域 -->
{
stickyComponents.length > 0 && (
<div
id="right-sidebar-sticky"
class="transition-all duration-700 flex flex-col w-full gap-4 sticky top-4"
>
{stickyComponents.map((component, index) => {
const renderData = renderComponent(
component,
index,
stickyComponents
);
if (!renderData) return null;
const { Component, props } = renderData;
return <Component {...props} />;
})}
</div>
)
}
</div>
<!-- 响应式样式和JavaScript -->
<style>
/* 响应式断点样式 */
@media (max-width: 768px) {
#right-sidebar {
display: var(--sidebar-mobile-display, block);
}
}
@media (min-width: 769px) and (max-width: 1024px) {
#right-sidebar {
display: var(--sidebar-tablet-display, block);
}
}
@media (min-width: 1025px) {
#right-sidebar {
display: var(--sidebar-desktop-display, block);
}
}
</style>
<script>
import { widgetManager } from "../../utils/widget-manager";
// 响应式布局管理
class RightSidebarManager {
constructor() {
this.init();
}
init() {
this.updateResponsiveDisplay();
window.addEventListener('resize', () => this.updateResponsiveDisplay());
// 监听SWUP内容替换事件
if (typeof window !== 'undefined' && (window as any).swup) {
(window as any).swup.hooks.on('content:replace', () => {
// 延迟执行以确保DOM已更新
setTimeout(() => {
this.updateResponsiveDisplay();
}, 100);
});
}
}
updateResponsiveDisplay() {
const breakpoints = widgetManager.getBreakpoints();
const width = window.innerWidth;
let deviceType: 'mobile' | 'tablet' | 'desktop';
if (width < breakpoints.mobile) {
deviceType = 'mobile';
} else if (width < breakpoints.tablet) {
deviceType = 'tablet';
} else {
deviceType = 'desktop';
}
const shouldShow = widgetManager.shouldShowSidebar(deviceType);
const sidebar = document.getElementById('right-sidebar');
if (sidebar) {
sidebar.style.setProperty(
`--sidebar-${deviceType}-display`,
shouldShow ? 'block' : 'none'
);
}
}
}
// 初始化右侧边栏管理器
new RightSidebarManager();
</script>

View File

@@ -0,0 +1,102 @@
---
// 动画测试组件
---
<div class="animation-test-container">
<div class="test-content transition-slide-in" id="test-content">
<h3>动画测试区域</h3>
<p>这个区域展示了参考yukina主题实现的滑入滑出动画效果</p>
<div class="test-buttons">
<button class="test-btn" onclick="testSlideIn()">滑入动画</button>
<button class="test-btn" onclick="testSlideOut()">滑出动画</button>
<button class="test-btn" onclick="resetAnimation()">重置</button>
</div>
</div>
</div>
<style>
.animation-test-container {
margin: 2rem 0;
padding: 1.5rem;
background: var(--color-bg-secondary, #f8f9fa);
border-radius: 8px;
border: 1px solid var(--color-border, #e9ecef);
}
.test-content {
padding: 1rem;
background: white;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.test-buttons {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.test-btn {
padding: 0.5rem 1rem;
background: var(--color-primary, #667eea);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.test-btn:hover {
background: var(--color-primary-hover, #5a6fd8);
transform: translateY(-1px);
}
.test-btn:active {
transform: translateY(0);
}
</style>
<script is:inline>
// 动画测试函数
function testSlideIn() {
const content = document.getElementById('test-content');
if (!content) return;
content.className = 'test-content transition-slide-in';
content.offsetHeight; // 强制重排
setTimeout(() => {
content.classList.add('is-active');
}, 50);
}
function testSlideOut() {
const content = document.getElementById('test-content');
if (!content) return;
content.className = 'test-content transition-slide-out is-active';
setTimeout(() => {
content.classList.add('is-leaving');
}, 50);
}
function resetAnimation() {
const content = document.getElementById('test-content');
if (!content) return;
content.className = 'test-content transition-slide-in is-active';
}
// 暴露函数给全局作用域以便HTML中的onclick可以调用
window.testSlideIn = testSlideIn;
window.testSlideOut = testSlideOut;
window.resetAnimation = resetAnimation;
// 页面加载时自动触发滑入动画
document.addEventListener('DOMContentLoaded', () => {
setTimeout(testSlideIn, 300);
});
</script>

View File

@@ -0,0 +1,236 @@
---
// import { Image } from "astro:assets";
import { siteConfig } from "../../config";
import type { FullscreenWallpaperConfig } from "../../types/config";
interface Props {
config: FullscreenWallpaperConfig;
className?: string;
}
const { config, className } = Astro.props;
// 获取当前设备类型的图片源
const getImageSources = async () => {
let srcConfig = config.src;
// 如果启用了图片API使用API图片
if (siteConfig.banner.imageApi?.enable && siteConfig.banner.imageApi?.url) {
try {
const response = await fetch(siteConfig.banner.imageApi.url);
const text = await response.text();
const apiImages = text.split("\n").filter((line) => line.trim());
if (apiImages.length > 0) {
// 将API图片同时用于桌面端和移动端
return {
desktop: apiImages,
mobile: apiImages,
};
}
} catch (error) {
console.warn("Failed to fetch images from API for wallpaper:", error);
}
}
const toArray = (src: string | string[] | undefined): string[] => {
if (Array.isArray(src)) return src;
if (typeof src === "string") return [src];
return [];
};
if (
typeof srcConfig === "object" &&
srcConfig !== null &&
!Array.isArray(srcConfig) &&
("desktop" in srcConfig || "mobile" in srcConfig)
) {
const srcObj = srcConfig as {
desktop?: string | string[];
mobile?: string | string[];
};
const desktop = toArray(srcObj.desktop);
const mobile = toArray(srcObj.mobile);
return {
desktop: desktop.length > 0 ? desktop : mobile,
mobile: mobile.length > 0 ? mobile : desktop,
};
}
// 如果是字符串或字符串数组,同时用于桌面端和移动端
const allImages = toArray(srcConfig as string | string[]);
return {
desktop: allImages,
mobile: allImages,
};
};
const imageSources = await getImageSources();
const hasDesktopImages = imageSources.desktop.length > 0;
const hasMobileImages = imageSources.mobile.length > 0;
// 如果没有任何图片源,不渲染
if (!hasDesktopImages && !hasMobileImages) {
return null;
}
// 轮播相关逻辑
const isCarouselEnabled =
config.carousel?.enable &&
(imageSources.desktop.length > 1 || imageSources.mobile.length > 1);
const carouselInterval = config.carousel?.interval || 5;
// 样式相关
const position = config.position || "center";
const zIndex = config.zIndex || -1;
const opacity = config.opacity || 0.8;
const blur = config.blur || 0;
// 生成位置类名
const getPositionClass = (pos: string) => {
switch (pos) {
case "top":
return "object-top";
case "bottom":
return "object-bottom";
default:
return "object-center";
}
};
const positionClass = getPositionClass(position);
---
<div
class:list={[
"fixed inset-0 w-full h-full overflow-hidden pointer-events-none",
className
]}
style={`z-index: ${zIndex}; opacity: ${opacity};`}
data-fullscreen-wallpaper
>
<script is:inline>
// 根据 localStorage 立即设置全屏壁纸初始显示状态,避免闪烁
(function() {
const wallpaperMode = localStorage.getItem('wallpaperMode') || 'banner';
const fullscreenWallpaper = document.currentScript.parentElement;
if (wallpaperMode !== 'fullscreen') {
fullscreenWallpaper.style.display = 'none';
}
})();
</script>
<!-- 桌面端壁纸 -->
{hasDesktopImages && (
<div class="hidden lg:block w-full h-full relative">
{imageSources.desktop.map((src, index) => {
const isLocal = !src.startsWith("http");
const imageClass = `absolute inset-0 w-full h-full object-cover transition-opacity duration-1000 ${positionClass}`;
if (isLocal) {
// 对于本地图片使用img标签而不是Image组件
return (
<img
src={src}
alt={`Desktop wallpaper ${index + 1}`}
class={imageClass}
data-carousel-item={isCarouselEnabled ? index : undefined}
style={blur > 0 ? `filter: blur(${blur}px);` : undefined}
/>
);
} else {
return (
<img
src={src}
alt={`Desktop wallpaper ${index + 1}`}
class={imageClass}
data-carousel-item={isCarouselEnabled ? index : undefined}
style={blur > 0 ? `filter: blur(${blur}px);` : undefined}
/>
);
}
})}
</div>
)}
<!-- 移动端壁纸 -->
{hasMobileImages && (
<div class="block lg:hidden w-full h-full relative">
{imageSources.mobile.map((src, index) => {
const isLocal = !src.startsWith("http");
const imageClass = `absolute inset-0 w-full h-full object-cover transition-opacity duration-1000 ${positionClass}`;
if (isLocal) {
// 对于本地图片使用img标签而不是Image组件
return (
<img
src={src}
alt={`Mobile wallpaper ${index + 1}`}
class={imageClass}
data-carousel-item={isCarouselEnabled ? index : undefined}
style={blur > 0 ? `filter: blur(${blur}px);` : undefined}
/>
);
} else {
return (
<img
src={src}
alt={`Mobile wallpaper ${index + 1}`}
class={imageClass}
data-carousel-item={isCarouselEnabled ? index : undefined}
style={blur > 0 ? `filter: blur(${blur}px);` : undefined}
/>
);
}
})}
</div>
)}
</div>
{isCarouselEnabled && (
<script is:inline define:vars={{ carouselInterval }}>
// 全屏壁纸轮播逻辑
function initFullscreenWallpaperCarousel() {
const wallpaperContainer = document.querySelector('[data-fullscreen-wallpaper]');
if (!wallpaperContainer) return;
const desktopItems = wallpaperContainer.querySelectorAll('.hidden.lg\\:block [data-carousel-item]');
const mobileItems = wallpaperContainer.querySelectorAll('.block.lg\\:hidden [data-carousel-item]');
function startCarousel(items) {
if (items.length <= 1) return;
let currentIndex = 0;
// 初始化:显示第一张,隐藏其他
items.forEach((item, index) => {
item.style.opacity = index === 0 ? '1' : '0';
});
// 开始轮播
setInterval(() => {
// 隐藏当前图片
items[currentIndex].style.opacity = '0';
// 切换到下一张
currentIndex = (currentIndex + 1) % items.length;
// 显示下一张图片
items[currentIndex].style.opacity = '1';
}, carouselInterval * 1000);
}
// 分别为桌面端和移动端启动轮播
if (desktopItems.length > 0) {
startCarousel(desktopItems);
}
if (mobileItems.length > 0) {
startCarousel(mobileItems);
}
}
// 页面加载完成后初始化轮播
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initFullscreenWallpaperCarousel);
} else {
initFullscreenWallpaperCarousel();
}
</script>
)}

View File

@@ -0,0 +1,181 @@
---
// 可靠的图标组件
// 提供加载状态管理和错误处理
export interface Props {
icon: string;
class?: string;
style?: string;
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
color?: string;
fallback?: string; // 备用图标或文本
loading?: "lazy" | "eager";
}
const {
icon,
class: className = "",
style = "",
size = "md",
color,
fallback = "●",
loading = "lazy",
} = Astro.props;
// 尺寸映射
const sizeClasses = {
xs: "text-xs",
sm: "text-sm",
md: "text-base",
lg: "text-lg",
xl: "text-xl",
"2xl": "text-2xl",
};
const sizeClass = sizeClasses[size] || sizeClasses.md;
const colorStyle = color ? `color: ${color};` : "";
const combinedStyle = `${colorStyle}${style}`;
const combinedClass = `${sizeClass} ${className}`.trim();
// 生成唯一ID
const iconId = `icon-${Math.random().toString(36).substring(2, 9)}`;
---
<span
class={`inline-flex items-center justify-center ${combinedClass}`}
style={combinedStyle}
data-icon-container={iconId}
>
<!-- 加载状态指示器 -->
<span
class="icon-loading animate-pulse opacity-50"
data-loading-indicator
>
{fallback}
</span>
<!-- 实际图标 -->
<iconify-icon
icon={icon}
class="icon-content opacity-0 transition-opacity duration-200"
data-icon-element
loading={loading}
></iconify-icon>
</span>
<script is:inline define:vars={{ iconId, icon }}>
// 图标加载和显示逻辑
(function() {
const container = document.querySelector(`[data-icon-container="${iconId}"]`);
if (!container) return;
const loadingIndicator = container.querySelector('[data-loading-indicator]');
const iconElement = container.querySelector('[data-icon-element]');
if (!loadingIndicator || !iconElement) return;
// 检查图标是否已经加载
function checkIconLoaded() {
// 检查iconify-icon元素是否已经渲染
const hasContent = iconElement.shadowRoot &&
iconElement.shadowRoot.children.length > 0;
if (hasContent) {
showIcon();
return true;
}
return false;
}
// 显示图标,隐藏加载指示器
function showIcon() {
loadingIndicator.style.display = 'none';
iconElement.classList.remove('opacity-0');
iconElement.classList.add('opacity-100');
}
// 显示加载指示器,隐藏图标
function showLoading() {
loadingIndicator.style.display = 'inline-flex';
iconElement.classList.remove('opacity-100');
iconElement.classList.add('opacity-0');
}
// 初始状态
showLoading();
// 监听图标加载事件
iconElement.addEventListener('load', () => {
showIcon();
});
// 监听图标加载错误
iconElement.addEventListener('error', () => {
// 保持显示fallback
console.warn(`Failed to load icon: ${icon}`);
});
// 使用MutationObserver监听shadow DOM变化
if (window.MutationObserver) {
const observer = new MutationObserver(() => {
if (checkIconLoaded()) {
observer.disconnect();
}
});
// 监听iconify-icon元素的变化
observer.observe(iconElement, {
childList: true,
subtree: true,
attributes: true
});
// 设置超时,避免无限等待
setTimeout(() => {
observer.disconnect();
if (!checkIconLoaded()) {
console.warn(`Icon load timeout: ${icon}`);
}
}, 5000);
}
// 立即检查一次(可能已经加载完成)
setTimeout(() => {
checkIconLoaded();
}, 100);
})();
</script>
<style>
.icon-loading {
min-width: 1em;
min-height: 1em;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-content {
display: inline-flex;
align-items: center;
justify-content: center;
}
[data-icon-container] {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1em;
min-height: 1em;
}
[data-icon-container] .icon-loading,
[data-icon-container] .icon-content {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,272 @@
---
// 全局Iconify加载器组件
// 在页面头部加载,确保图标库尽早可用
export interface Props {
preloadIcons?: string[]; // 需要预加载的图标列表
timeout?: number; // 加载超时时间
retryCount?: number; // 重试次数
}
const { preloadIcons = [], timeout = 10000, retryCount = 3 } = Astro.props;
---
<!-- Iconify图标库加载器 -->
<script is:inline define:vars={{ preloadIcons, timeout, retryCount }}>
// 全局图标加载逻辑
(function() {
'use strict';
// 避免重复加载
if (window.__iconifyLoaderInitialized) {
return;
}
window.__iconifyLoaderInitialized = true;
// 图标加载器类
class IconifyLoader {
constructor() {
this.isLoaded = false;
this.isLoading = false;
this.loadPromise = null;
this.observers = new Set();
this.preloadQueue = new Set();
}
async load(options = {}) {
const { timeout: loadTimeout = timeout, retryCount: maxRetries = retryCount } = options;
if (this.isLoaded) {
return Promise.resolve();
}
if (this.isLoading && this.loadPromise) {
return this.loadPromise;
}
this.isLoading = true;
this.loadPromise = this.loadWithRetry(loadTimeout, maxRetries);
try {
await this.loadPromise;
this.isLoaded = true;
this.notifyObservers();
await this.processPreloadQueue();
} catch (error) {
console.error('Failed to load Iconify:', error);
throw error;
} finally {
this.isLoading = false;
}
}
async loadWithRetry(timeout, retryCount) {
for (let attempt = 1; attempt <= retryCount; attempt++) {
try {
await this.loadScript(timeout);
return;
} catch (error) {
console.warn(`Iconify load attempt ${attempt} failed:`, error);
if (attempt === retryCount) {
throw new Error(`Failed to load Iconify after ${retryCount} attempts`);
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
}
loadScript(timeout) {
return new Promise((resolve, reject) => {
// 检查是否已经存在
if (this.isIconifyReady()) {
resolve();
return;
}
const existingScript = document.querySelector('script[src*="iconify-icon"]');
if (existingScript) {
this.waitForIconifyReady().then(resolve).catch(reject);
return;
}
const script = document.createElement('script');
script.src = 'https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js';
script.async = true;
script.crossOrigin = 'anonymous';
const timeoutId = setTimeout(() => {
script.remove();
reject(new Error('Script load timeout'));
}, timeout);
script.onload = () => {
clearTimeout(timeoutId);
this.waitForIconifyReady().then(resolve).catch(reject);
};
script.onerror = () => {
clearTimeout(timeoutId);
script.remove();
reject(new Error('Script load error'));
};
document.head.appendChild(script);
});
}
waitForIconifyReady(maxWait = 5000) {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const checkReady = () => {
if (this.isIconifyReady()) {
resolve();
return;
}
if (Date.now() - startTime > maxWait) {
reject(new Error('Iconify initialization timeout'));
return;
}
setTimeout(checkReady, 50);
};
checkReady();
});
}
isIconifyReady() {
return typeof window !== 'undefined' &&
'customElements' in window &&
customElements.get('iconify-icon') !== undefined;
}
onLoad(callback) {
if (this.isLoaded) {
callback();
} else {
this.observers.add(callback);
}
}
notifyObservers() {
this.observers.forEach(callback => {
try {
callback();
} catch (error) {
console.error('Error in icon load observer:', error);
}
});
this.observers.clear();
}
addToPreloadQueue(icons) {
if (Array.isArray(icons)) {
icons.forEach(icon => this.preloadQueue.add(icon));
} else {
this.preloadQueue.add(icons);
}
if (this.isLoaded) {
this.processPreloadQueue();
}
}
async processPreloadQueue() {
if (this.preloadQueue.size === 0) return;
const iconsToLoad = Array.from(this.preloadQueue);
this.preloadQueue.clear();
await this.preloadIcons(iconsToLoad);
}
async preloadIcons(icons) {
if (!this.isLoaded || icons.length === 0) return;
return new Promise((resolve) => {
let loadedCount = 0;
const totalIcons = icons.length;
const tempElements = [];
const cleanup = () => {
tempElements.forEach(el => {
if (el.parentNode) {
el.parentNode.removeChild(el);
}
});
};
const checkComplete = () => {
loadedCount++;
if (loadedCount >= totalIcons) {
cleanup();
resolve();
}
};
icons.forEach(icon => {
const tempIcon = document.createElement('iconify-icon');
tempIcon.setAttribute('icon', icon);
tempIcon.style.cssText = 'position:absolute;top:-9999px;left:-9999px;width:1px;height:1px;opacity:0;pointer-events:none;';
tempIcon.addEventListener('load', checkComplete);
tempIcon.addEventListener('error', checkComplete);
tempElements.push(tempIcon);
document.body.appendChild(tempIcon);
});
// 设置超时清理
setTimeout(() => {
cleanup();
resolve();
}, 3000);
});
}
}
// 创建全局实例
window.__iconifyLoader = new IconifyLoader();
// 立即开始加载
window.__iconifyLoader.load().catch(error => {
console.error('Failed to initialize Iconify:', error);
});
// 如果有预加载图标,添加到队列
if (preloadIcons && preloadIcons.length > 0) {
window.__iconifyLoader.addToPreloadQueue(preloadIcons);
}
// 导出便捷函数到全局
window.loadIconify = () => window.__iconifyLoader?.load();
window.preloadIcons = (icons) => window.__iconifyLoader?.addToPreloadQueue(icons);
window.onIconifyReady = (callback) => {
return window.__iconifyLoader?.onLoad ? window.__iconifyLoader.onLoad(callback) : null;
};
// 页面可见性变化时重新检查
document.addEventListener('visibilitychange', () => {
if (!document.hidden && !window.__iconifyLoader.isLoaded) {
window.__iconifyLoader.load().catch(console.error);
}
});
})();
</script>
<!-- 为不支持JavaScript的情况提供备用方案 -->
<noscript>
<style>
iconify-icon {
display: none;
}
.icon-fallback {
display: inline-block;
}
</style>
</noscript>

View File

@@ -0,0 +1,55 @@
---
import path from "node:path";
interface Props {
id?: string;
src: string;
class?: string;
alt?: string;
position?: string;
basePath?: string;
}
import { Image } from "astro:assets";
import { url } from "../../utils/url-utils";
const { id, src, alt, position = "center", basePath = "/" } = Astro.props;
const className = Astro.props.class;
const isLocal = !(
src.startsWith("/") ||
src.startsWith("http") ||
src.startsWith("https") ||
src.startsWith("data:")
);
const isPublic = src.startsWith("/");
// TODO temporary workaround for images dynamic import
// https://github.com/withastro/astro/issues/3373
// biome-ignore lint/suspicious/noImplicitAnyLet: <check later>
let img: ImageMetadata | undefined;
if (isLocal) {
const files = import.meta.glob<ImageMetadata>("../../**", {
import: "default",
});
let normalizedPath = path
.normalize(path.join("../../", basePath, src))
.replace(/\\/g, "/");
const file = files[normalizedPath];
if (!file) {
console.error(
`\n[ERROR] Image file not found: ${normalizedPath.replace("../../", "src/")}`,
);
} else {
img = await file();
}
}
const imageClass = "w-full h-full object-cover";
const imageStyle = `object-position: ${position}`;
---
<div id={id} class:list={[className, 'overflow-hidden relative']}>
<div class="transition absolute inset-0 dark:bg-black/10 bg-opacity-50 pointer-events-none"></div>
{isLocal && img && <Image src={img} alt={alt || ""} class={imageClass} style={imageStyle}/>}
{!isLocal && <img src={isPublic ? url(src) : src} alt={alt || ""} class={imageClass} style={imageStyle}/>}
</div>

View File

@@ -0,0 +1,48 @@
---
import { Icon } from "astro-icon/components";
import { licenseConfig, profileConfig } from "../../config";
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
import { formatDateToYYYYMMDD } from "../../utils/date-utils";
interface Props {
title: string;
id: string;
pubDate: Date;
class: string;
author: string;
sourceLink: string;
licenseName: string;
licenseUrl: string;
}
const { title, pubDate, author, sourceLink, licenseName, licenseUrl } =
Astro.props;
const className = Astro.props.class;
const profileConf = profileConfig;
const licenseConf = licenseConfig;
const postUrl = sourceLink || decodeURIComponent(Astro.url.toString());
---
<div class={`relative transition overflow-hidden bg-[var(--license-block-bg)] py-5 px-6 ${className}`}>
<div class="transition font-bold text-black/75 dark:text-white/75">
{title}
</div>
<a href={postUrl} class="link text-[var(--primary)]">
{postUrl}
</a>
<div class="flex gap-6 mt-2">
<div>
<div class="transition text-black/30 dark:text-white/30 text-sm">{i18n(I18nKey.author)}</div>
<div class="transition text-black/75 dark:text-white/75 line-clamp-2">{author || profileConf.name}</div>
</div>
<div>
<div class="transition text-black/30 dark:text-white/30 text-sm">{i18n(I18nKey.publishedAt)}</div>
<div class="transition text-black/75 dark:text-white/75 line-clamp-2">{formatDateToYYYYMMDD(pubDate)}</div>
</div>
<div>
<div class="transition text-black/30 dark:text-white/30 text-sm">{i18n(I18nKey.license)}</div>
<a href={licenseName ? (licenseUrl || undefined) : licenseConf.url} target="_blank" class="link text-[var(--primary)] line-clamp-2">{licenseName || licenseConf.name}</a>
</div>
</div>
<Icon name="fa6-brands:creative-commons" class="transition text-[15rem] absolute pointer-events-none right-6 top-1/2 -translate-y-1/2 text-black/5 dark:text-white/5"></Icon>
</div>

View File

@@ -0,0 +1,136 @@
---
// 只加载基础的等宽字体,减少加载时间
import "@fontsource-variable/jetbrains-mono";
interface Props {
class: string;
}
const className = Astro.props.class;
---
<div data-pagefind-body class={`prose dark:prose-invert prose-base !max-w-none custom-md ${className}`}>
<!--<div class="prose dark:prose-invert max-w-none custom-md">-->
<!--<div class="max-w-none custom-md">-->
<slot/>
</div>
<script>
document.addEventListener("click", function (e: MouseEvent) {
const target = e.target as Element | null;
if (target && target.classList.contains("copy-btn")) {
const preEle = target.closest("pre");
const codeEle = preEle?.querySelector("code");
// 精确的代码提取逻辑
let code = '';
if (codeEle) {
// 获取所有代码行元素
const lineElements = codeEle.querySelectorAll('span.line');
if (lineElements.length > 0) {
// 对于有行结构的代码块,精确处理每一行
const lines: string[] = [];
for (let i = 0; i < lineElements.length; i++) {
const lineElement = lineElements[i];
// 直接获取文本内容,不添加额外处理
const lineText = lineElement.textContent || '';
lines.push(lineText);
}
// 重要:使用 \n 连接行,而不是 \n\n 或其他方式
code = lines.join('\n');
} else {
// 对于没有行结构的代码块
const codeElements = codeEle.querySelectorAll('.code:not(summary *)');
if (codeElements.length > 0) {
const lines: string[] = [];
for (let i = 0; i < codeElements.length; i++) {
const el = codeElements[i];
const lineText = el.textContent || '';
lines.push(lineText);
}
code = lines.join('\n');
} else {
// 最后回退到直接使用整个code元素的文本内容
code = codeEle.textContent || '';
}
}
}
// 处理连续空行:改进的逻辑
code = code.replace(/\n\n\n+/g, function(match) {
// 计算连续换行符的数量
const newlineCount = match.length;
// 计算空行数量换行符数量减1
const emptyLineCount = newlineCount - 1;
// 偶数空行除以2
// 奇数空行:(空行数+1)/2 向下取整
let resultEmptyLines: number;
if (emptyLineCount % 2 === 0) {
// 偶数
resultEmptyLines = emptyLineCount / 2;
} else {
// 奇数
resultEmptyLines = Math.floor((emptyLineCount + 1) / 2);
}
// 至少保留一个空行
if (resultEmptyLines < 1) resultEmptyLines = 1;
// 返回对应数量的换行符
return '\n'.repeat(resultEmptyLines + 1);
});
// 尝试多种复制方法
const copyToClipboard = async (text: string) => {
try {
// 优先使用 Clipboard API
await navigator.clipboard.writeText(text);
} catch (clipboardErr) {
console.warn('Clipboard API 失败,尝试备用方案:', clipboardErr);
// 备用方案:使用 document.execCommand
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
// @ts-ignore
// const successful = document.execCommand('copy');
const successful = true; // document.execCommand is deprecated
if (!successful) {
throw new Error('execCommand 返回 false');
}
} catch (execErr) {
console.error('execCommand 也失败了:', execErr);
throw new Error('所有复制方法都失败了');
} finally {
document.body.removeChild(textArea);
}
}
};
copyToClipboard(code).then(() => {
const timeoutId = target.getAttribute("data-timeout-id");
if (timeoutId) {
clearTimeout(parseInt(timeoutId));
}
target.classList.add("success");
// 设置新的timeout并保存ID到按钮的自定义属性中
const newTimeoutId = setTimeout(() => {
target.classList.remove("success");
}, 1000);
target.setAttribute("data-timeout-id", newTimeoutId.toString());
}).catch(err => {
console.error('复制失败:', err);
// 可以在这里添加用户提示
});
}
});
</script>

View File

@@ -0,0 +1,324 @@
---
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
// 计算SVG圆环路径数据的函数Donut Chart
interface ChartSegment {
startAngle: number;
endAngle: number;
angle: number;
}
const calculatePathData = (segment: ChartSegment) => {
if (segment.angle <= 0) return "";
// 外圆和内圆半径
const outerRadius = 40;
const innerRadius = 22; // 内圆半径,创建圆环效果
const centerX = 50;
const centerY = 50;
// 外圆起始点和结束点
const startAngleRad = ((segment.startAngle - 90) * Math.PI) / 180;
const endAngleRad = ((segment.endAngle - 90) * Math.PI) / 180;
const outerStartX = centerX + outerRadius * Math.cos(startAngleRad);
const outerStartY = centerY + outerRadius * Math.sin(startAngleRad);
const outerEndX = centerX + outerRadius * Math.cos(endAngleRad);
const outerEndY = centerY + outerRadius * Math.sin(endAngleRad);
// 内圆起始点和结束点(方向相反)
const innerStartX = centerX + innerRadius * Math.cos(endAngleRad);
const innerStartY = centerY + innerRadius * Math.sin(endAngleRad);
const innerEndX = centerX + innerRadius * Math.cos(startAngleRad);
const innerEndY = centerY + innerRadius * Math.sin(startAngleRad);
// 大弧标志
const largeArcFlag = segment.angle > 180 ? 1 : 0;
// 创建圆环路径(外圆弧 -> 内圆弧反向)
return [
`M ${outerStartX} ${outerStartY}`,
`A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} 1 ${outerEndX} ${outerEndY}`,
`L ${innerStartX} ${innerStartY}`,
`A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} 0 ${innerEndX} ${innerEndY}`,
"Z",
].join(" ");
};
interface Props {
stats: {
total: number;
byLevel: {
beginner: number;
intermediate: number;
advanced: number;
expert: number;
};
byCategory: {
frontend: number;
backend: number;
database: number;
tools: number;
other: number;
};
};
totalExperience: {
years: number;
months: number;
};
}
const { stats, totalExperience } = Astro.props;
// 颜色配置
const levelColors = {
expert: "#EF4444", // red-500
advanced: "#F97316", // orange-500
intermediate: "#EAB308", // yellow-500
beginner: "#22C55E", // green-500
};
const categoryColors = {
frontend: "#3B82F6", // blue-500
backend: "#8B5CF6", // violet-500
database: "#10B981", // emerald-500
tools: "#F59E0B", // amber-500
other: "#EC4899", // pink-500
};
// 计算扇形图数据(按技能等级)
const levelData = [
{
name: i18n(I18nKey.skillLevelExpert),
value: stats.byLevel.expert,
color: levelColors.expert,
},
{
name: i18n(I18nKey.skillLevelAdvanced),
value: stats.byLevel.advanced,
color: levelColors.advanced,
},
{
name: i18n(I18nKey.skillLevelIntermediate),
value: stats.byLevel.intermediate,
color: levelColors.intermediate,
},
{
name: i18n(I18nKey.skillLevelBeginner),
value: stats.byLevel.beginner,
color: levelColors.beginner,
},
];
// 计算矩形图数据(按技能分类)
const categoryData = [
{
name: i18n(I18nKey.skillsFrontend),
value: stats.byCategory.frontend,
color: categoryColors.frontend,
},
{
name: i18n(I18nKey.skillsBackend),
value: stats.byCategory.backend,
color: categoryColors.backend,
},
{
name: i18n(I18nKey.skillsDatabase),
value: stats.byCategory.database,
color: categoryColors.database,
},
{
name: i18n(I18nKey.skillsTools),
value: stats.byCategory.tools,
color: categoryColors.tools,
},
{
name: i18n(I18nKey.skillsOther),
value: stats.byCategory.other,
color: categoryColors.other,
},
];
// 计算扇形图角度
const totalLevel = levelData.reduce((sum, item) => sum + item.value, 0);
let currentAngle = 0;
const levelSegments = levelData.map((item) => {
const angle = totalLevel > 0 ? (item.value / totalLevel) * 360 : 0;
const startAngle = currentAngle;
currentAngle += angle;
return {
...item,
startAngle,
endAngle: currentAngle,
angle,
};
});
// 计算矩形图最大值用于比例计算
const maxCategory = Math.max(...categoryData.map((item) => item.value), 1);
// 计算百分比用于显示
const calculatePercentage = (value: number, total: number) => {
return total > 0 ? Math.round((value / total) * 100) : 0;
};
---
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- 扇形图 -->
<div class="bg-transparent rounded-xl border border-black/10 dark:border-white/10 p-6 shadow-sm hover:shadow-lg transition-shadow duration-300">
<h3 class="text-xl font-bold text-black/90 dark:text-white/90 mb-6 flex items-center">
<span class="w-3 h-3 rounded-full bg-blue-500 mr-2"></span>
{i18n(I18nKey.skillsByLevel)}
</h3>
<div class="flex flex-col items-center">
<div class="relative w-64 h-64">
{
totalLevel > 0 ? (
<svg viewBox="0 0 100 100" class="w-full h-full">
<defs>
<!-- 为每个技能等级定义更协调的径向渐变 -->
<radialGradient id="expert-gradient" cx="50%" cy="50%" r="70%">
<stop offset="0%" stop-color="#FECACA" />
<stop offset="100%" stop-color="#EF4444" />
</radialGradient>
<radialGradient id="advanced-gradient" cx="50%" cy="50%" r="70%">
<stop offset="0%" stop-color="#FED7AA" />
<stop offset="100%" stop-color="#F97316" />
</radialGradient>
<radialGradient id="intermediate-gradient" cx="50%" cy="50%" r="70%">
<stop offset="0%" stop-color="#FEF08A" />
<stop offset="100%" stop-color="#EAB308" />
</radialGradient>
<radialGradient id="beginner-gradient" cx="50%" cy="50%" r="70%">
<stop offset="0%" stop-color="#BBF7D0" />
<stop offset="100%" stop-color="#22C55E" />
</radialGradient>
</defs>
{levelSegments.map((segment, index) => {
const pathData = calculatePathData(segment);
if (!pathData) return null;
// 根据技能等级选择对应的渐变
let gradientId = "";
if (segment.name === i18n(I18nKey.skillLevelExpert)) {
gradientId = "expert-gradient";
} else if (segment.name === i18n(I18nKey.skillLevelAdvanced)) {
gradientId = "advanced-gradient";
} else if (segment.name === i18n(I18nKey.skillLevelIntermediate)) {
gradientId = "intermediate-gradient";
} else if (segment.name === i18n(I18nKey.skillLevelBeginner)) {
gradientId = "beginner-gradient";
}
return (
<path
data-key={index}
d={pathData}
fill={`url(#${gradientId})`}
class="transition-all duration-300 hover:opacity-90"
/>
);
})}
</svg>
) : (
<div class="w-full h-full flex items-center justify-center">
<div class="text-gray-400 dark:text-gray-500">
{i18n(I18nKey.noData)}
</div>
</div>
)
}
</div>
<!-- 图例 -->
<div class="mt-6 grid grid-cols-2 gap-3 w-full max-w-xs">
{levelData.map((item, index) => {
const percentage = calculatePercentage(item.value, totalLevel);
return (
<div data-key={index} class="flex items-center justify-between p-2 rounded-lg hover:bg-gray-600/10 dark:hover:bg-gray-700/50 transition-colors">
<div class="flex items-center">
<div class="w-3 h-3 rounded-full mr-2" style={`background-color: ${item.color}`}></div>
<span class="text-sm text-black/70 dark:text-white/70">
{item.name}
</span>
</div>
<div class="flex items-center">
<span class="text-sm font-medium text-black/90 dark:text-white/90 mr-1">
{item.value}
</span>
<span class="text-xs text-black/50 dark:text-white/50">
{percentage}%
</span>
</div>
</div>
);
})}
</div>
</div>
</div>
<!-- 矩形图和统计数据 -->
<div class="bg-transparent rounded-xl border border-black/10 dark:border-white/10 p-6 shadow-sm hover:shadow-lg transition-shadow duration-300">
<h3 class="text-xl font-bold text-black/90 dark:text-white/90 mb-6 flex items-center">
<span class="w-3 h-3 rounded-full bg-purple-500 mr-2"></span>
{i18n(I18nKey.skillsByCategory)}
</h3>
<div class="space-y-5">
{categoryData.map((item, index) => {
const percentage = maxCategory > 0 ? (item.value / maxCategory) * 100 : 0;
const actualPercentage = calculatePercentage(item.value, stats.total);
return (
<div data-key={index}>
<div class="flex justify-between text-sm mb-2">
<div class="flex items-center">
<span class="text-black/90 dark:text-white/90 font-medium">{item.name}</span>
<span class="ml-2 text-xs px-2 py-0.5 rounded-full bg-gray-600/20 dark:bg-gray-700 text-black/60 dark:text-white/60">
{item.value}
</span>
</div>
<span class="text-black/70 dark:text-white/70 font-medium">{actualPercentage}%</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3">
<div
class="h-3 rounded-full transition-all duration-300"
style={`width: ${percentage}%; background-color: ${item.color};`}
></div>
</div>
</div>
);
})}
<!-- 总经验统计 -->
<div class="pt-5 mt-5 border-t border-black/10 dark:border-white/10">
<div class="grid grid-cols-2 gap-4">
<div class="bg-blue-500/10 dark:bg-blue-900/30 rounded-lg p-4 border border-blue-500/20 dark:border-blue-900/30">
<div class="text-sm text-black/60 dark:text-white/60 mb-1">
{i18n(I18nKey.skillExperience)}
</div>
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">
{totalExperience.years}<span class="text-lg">.{totalExperience.months.toString().padStart(2, '0')}</span>
</div>
<div class="text-xs text-black/50 dark:text-white/50 mt-1">
{i18n(I18nKey.skillYears)}
</div>
</div>
<div class="bg-green-500/10 dark:bg-green-900/30 rounded-lg p-4 border border-green-500/20 dark:border-green-900/30">
<div class="text-sm text-black/60 dark:text-white/60 mb-1">
{i18n(I18nKey.skillsTotal)}
</div>
<div class="text-2xl font-bold text-green-600 dark:text-green-400">
{stats.total}
</div>
<div class="text-xs text-black/50 dark:text-white/50 mt-1">
{i18n(I18nKey.skills)}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 添加CSS样式 -->

View File

@@ -0,0 +1,82 @@
---
import { Icon } from "astro-icon/components";
import { announcementConfig } from "../../config";
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
import WidgetLayout from "./WidgetLayout.astro";
const config = announcementConfig;
interface Props {
class?: string;
style?: string;
}
const className = Astro.props.class;
const style = Astro.props.style;
---
<!-- 组件显示现在由sidebarLayoutConfig统一控制无需检查config.enable -->
<WidgetLayout
name={config.title || i18n(I18nKey.announcement)}
id="announcement"
class={className}
style={style}
>
<div>
<!-- 公告栏内容 -->
<div class="text-neutral-600 dark:text-neutral-300 leading-relaxed mb-3">
{config.content}
</div>
<!-- 可选链接和关闭按钮 -->
<div class="flex items-center justify-between gap-3">
<div>
{config.link && config.link.enable !== false && (
<a
href={config.link.url}
target={config.link.external ? "_blank" : "_self"}
rel={config.link.external ? "noopener noreferrer" : undefined}
class="btn-regular rounded-lg px-3 py-1.5 text-sm font-medium active:scale-95 transition-transform"
>
{config.link.text}
</a>
)}
</div>
{config.closable && (
<button
class="btn-regular rounded-lg h-8 w-8 text-sm hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
onclick="closeAnnouncement()"
aria-label={i18n(I18nKey.announcementClose)}
>
<Icon name="fa6-solid:xmark" class="text-sm" />
</button>
)}
</div>
</div>
</WidgetLayout>
<script>
function closeAnnouncement() {
// 通过data-id属性找到整个widget-layout元素
const widgetLayout = document.querySelector('widget-layout[data-id="announcement"]') as HTMLElement;
if (widgetLayout) {
// 隐藏整个widget-layout元素
widgetLayout.style.display = 'none';
// 保存关闭状态到localStorage
localStorage.setItem('announcementClosed', 'true');
}
}
// 页面加载时检查是否已关闭
document.addEventListener('DOMContentLoaded', function() {
const widgetLayout = document.querySelector('widget-layout[data-id="announcement"]') as HTMLElement;
if (widgetLayout && localStorage.getItem('announcementClosed') === 'true') {
widgetLayout.style.display = 'none';
}
});
// 使公告栏函数全局可用
window.closeAnnouncement = closeAnnouncement;
</script>

View File

@@ -0,0 +1,282 @@
---
import { siteConfig } from "../../config";
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
import { getSortedPosts } from "../../utils/content-utils";
import WidgetLayout from "./WidgetLayout.astro";
interface Props {
class?: string;
style?: string;
}
const { class: className, style } = Astro.props;
// 获取所有文章
const posts = await getSortedPosts();
// 创建文章日期映射格式YYYY-MM-DD -> 文章列表)
const postDateMap = new Map<string, { id: string; title: string; published: Date }[]>();
posts.forEach((post) => {
const date = new Date(post.data.published);
const dateKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
const existingPosts = postDateMap.get(dateKey) || [];
postDateMap.set(dateKey, [...existingPosts, {
id: post.id,
title: post.data.title,
published: post.data.published
}]);
});
// 序列化所有文章数据供客户端使用
const allPostsData = posts.map((post) => ({
id: post.id,
title: post.data.title,
published: post.data.published.toISOString(),
}));
// 月份名称(使用 i18n
const monthNames = [
i18n(I18nKey.calendarJanuary),
i18n(I18nKey.calendarFebruary),
i18n(I18nKey.calendarMarch),
i18n(I18nKey.calendarApril),
i18n(I18nKey.calendarMay),
i18n(I18nKey.calendarJune),
i18n(I18nKey.calendarJuly),
i18n(I18nKey.calendarAugust),
i18n(I18nKey.calendarSeptember),
i18n(I18nKey.calendarOctober),
i18n(I18nKey.calendarNovember),
i18n(I18nKey.calendarDecember),
];
// 星期名称(简写,使用 i18n
const weekDays = [
i18n(I18nKey.calendarSunday),
i18n(I18nKey.calendarMonday),
i18n(I18nKey.calendarTuesday),
i18n(I18nKey.calendarWednesday),
i18n(I18nKey.calendarThursday),
i18n(I18nKey.calendarFriday),
i18n(I18nKey.calendarSaturday),
];
// 判断是否为中文环境
const isChinese = siteConfig.lang.startsWith("zh");
const yearSuffix = isChinese ? "年" : " ";
---
<WidgetLayout id="calendar-widget" class={`hidden md:block ${className}`} style={style}>
<span slot="title-icon" id="calendar-widget-title" class="flex-1 text-left"></span>
<div class="calendar-container">
<!-- 星期标题 -->
<div class="weekdays grid grid-cols-7 gap-1 mb-2">
{weekDays.map(day => (
<div class="text-center text-xs text-neutral-500 dark:text-neutral-400 font-medium">
{day}
</div>
))}
</div>
<!-- 日历格子(由客户端动态生成) -->
<div class="calendar-grid grid grid-cols-7 gap-1" id="calendar-grid">
<!-- 将由 JavaScript 填充 -->
</div>
<!-- 文章列表 -->
<div id="calendar-posts" class="mt-3">
<div class="border-t border-neutral-200 dark:border-neutral-700 mb-2" id="calendar-posts-divider" style="display: none;"></div>
<div class="flex flex-col gap-1" id="calendar-posts-list">
<!-- 将由 JavaScript 填充 -->
</div>
</div>
</div>
</WidgetLayout>
<script is:inline define:vars={{ postDateMap: Object.fromEntries(postDateMap), allPostsData, monthNames, weekDays, yearSuffix }}>
// 客户端动态渲染日历
function renderCalendar() {
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth();
const currentDate = now.getDate();
// 更新 Widget 标题
const titleElement = document.getElementById('calendar-widget-title');
if (titleElement) {
titleElement.textContent = `${currentYear}${yearSuffix}${monthNames[currentMonth]}`;
}
// 获取月份的第一天是星期几
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay();
// 获取当月天数
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
// 生成日历格子
const calendarGrid = document.getElementById('calendar-grid');
if (!calendarGrid) return;
const calendarDays = [];
// 添加空白格子(月初空白)
for (let i = 0; i < firstDayOfMonth; i++) {
calendarDays.push({ day: null, hasPost: false, count: 0, dateKey: "" });
}
// 添加每一天
for (let day = 1; day <= daysInMonth; day++) {
const dateKey = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const posts = postDateMap[dateKey] || [];
const count = posts.length;
calendarDays.push({
day,
hasPost: count > 0,
count,
dateKey
});
}
// 渲染日历格子
calendarGrid.innerHTML = calendarDays.map(({day, hasPost, count, dateKey}) => {
const isToday = day === currentDate;
const classes = [
"calendar-day aspect-square flex items-center justify-center rounded text-sm relative cursor-pointer"
];
if (!day) {
classes.push("text-neutral-400 dark:text-neutral-600");
} else if (!hasPost) {
classes.push("text-neutral-700 dark:text-neutral-300");
} else {
classes.push("text-neutral-900 dark:text-neutral-100 font-bold");
}
if (isToday) {
classes.push("ring-2 ring-[var(--primary)]");
}
return `
<div
class="${classes.join(' ')}"
data-date="${dateKey}"
data-has-post="${hasPost}"
>
${day || ''}
${hasPost ? '<span class="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-[var(--primary)]"></span>' : ''}
${hasPost && count > 1 ? `<span class="absolute top-0 right-0 text-[10px] text-[var(--primary)] font-bold">${count}</span>` : ''}
</div>
`;
}).join('');
// 获取当月所有文章
const currentMonthPosts = allPostsData.filter(post => {
const date = new Date(post.published);
return date.getFullYear() === currentYear && date.getMonth() === currentMonth;
});
// 显示当月文章列表
showMonthlyPosts(currentMonthPosts);
// 添加点击事件监听
setupClickHandlers(currentMonthPosts);
}
// 显示当月所有文章
function showMonthlyPosts(currentMonthPosts) {
const postsList = document.getElementById('calendar-posts-list');
const divider = document.getElementById('calendar-posts-divider');
if (postsList) {
postsList.innerHTML = currentMonthPosts.map(post => `
<a href="/posts/${post.id}/" class="block text-sm text-neutral-700 dark:text-neutral-300 hover:text-[var(--primary)] dark:hover:text-[var(--primary)] transition-colors truncate px-2 py-1 rounded hover:bg-[var(--btn-plain-bg-hover)]">
${post.title}
</a>
`).join('');
// 显示/隐藏分割线
if (divider) {
divider.style.display = currentMonthPosts.length > 0 ? 'block' : 'none';
}
}
}
// 设置日历格子点击事件
function setupClickHandlers(currentMonthPosts) {
const calendarDays = document.querySelectorAll('.calendar-day[data-date]');
const postsList = document.getElementById('calendar-posts-list');
const divider = document.getElementById('calendar-posts-divider');
let currentSelectedDay = null;
calendarDays.forEach(dayElement => {
dayElement.addEventListener('click', () => {
const dateKey = dayElement.getAttribute('data-date');
const hasPost = dayElement.getAttribute('data-has-post') === 'true';
if (!hasPost || !dateKey) return;
// 切换选中状态
if (currentSelectedDay === dayElement) {
// 取消选中,恢复显示当月所有文章
dayElement.classList.remove('bg-[var(--primary)]', 'bg-opacity-10');
currentSelectedDay = null;
showMonthlyPosts(currentMonthPosts);
return;
}
// 移除之前选中的样式
if (currentSelectedDay) {
currentSelectedDay.classList.remove('bg-[var(--primary)]', 'bg-opacity-10');
}
// 添加选中样式
dayElement.classList.add('bg-[var(--primary)]', 'bg-opacity-10');
currentSelectedDay = dayElement;
// 获取该日期的文章
const posts = postDateMap[dateKey] || [];
if (posts.length > 0 && postsList) {
// 渲染文章列表
postsList.innerHTML = posts.map(post => `
<a href="/posts/${post.id}/" class="block text-sm text-neutral-700 dark:text-neutral-300 hover:text-[var(--primary)] dark:hover:text-[var(--primary)] transition-colors truncate px-2 py-1 rounded hover:bg-[var(--btn-plain-bg-hover)]">
${post.title}
</a>
`).join('');
// 显示分割线
if (divider) {
divider.style.display = 'block';
}
}
});
});
}
// 页面加载时渲染日历
renderCalendar();
// 每天午夜更新一次日历(可选)
const now = new Date();
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
const msUntilMidnight = tomorrow.getTime() - now.getTime();
setTimeout(() => {
renderCalendar();
// 之后每24小时更新一次
setInterval(renderCalendar, 24 * 60 * 60 * 1000);
}, msUntilMidnight);
</script>
<style>
.calendar-day {
transition: all 0.2s ease;
min-height: 32px;
}
.calendar-day[data-has-post="true"]:hover {
transform: translateY(-2px);
}
</style>

View File

@@ -0,0 +1,41 @@
---
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
import { getCategoryList } from "../../utils/content-utils";
import { widgetManager } from "../../utils/widget-manager";
import ButtonLink from "../control/ButtonLink.astro";
import WidgetLayout from "./WidgetLayout.astro";
const categories = await getCategoryList();
const COLLAPSED_HEIGHT = "7.5rem";
// 使用统一的组件管理器检查是否应该折叠
const categoriesComponent = widgetManager
.getConfig()
.components.find((c) => c.type === "categories");
const isCollapsed = categoriesComponent
? widgetManager.isCollapsed(categoriesComponent, categories.length)
: false;
interface Props {
class?: string;
style?: string;
}
const className = Astro.props.class;
const style = Astro.props.style;
---
<WidgetLayout name={i18n(I18nKey.categories)} id="categories" isCollapsed={isCollapsed} collapsedHeight={COLLAPSED_HEIGHT}
class={className} style={style}
>
{categories.map((c) =>
<ButtonLink
url={c.url}
badge={String(c.count)}
label={`View all posts in the ${c.name.trim()} category`}
>
{c.name.trim()}
</ButtonLink>
)}
</WidgetLayout>

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import Icon from "@iconify/svelte";
import { getDefaultHue, getHue, setHue } from "@utils/setting-utils";
let hue = getHue();
const defaultHue = getDefaultHue();
function resetHue() {
hue = getDefaultHue();
}
$: if (hue || hue === 0) {
setHue(hue);
}
</script>
<div id="display-setting" class="float-panel float-panel-closed absolute transition-all w-80 right-4 px-4 py-4">
<div class="flex flex-row gap-2 mb-3 items-center justify-between">
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
before:absolute before:-left-3 before:top-[0.33rem]"
>
{i18n(I18nKey.themeColor)}
<button aria-label="Reset to Default" class="btn-regular w-7 h-7 rounded-md active:scale-90"
class:opacity-0={hue === defaultHue} class:pointer-events-none={hue === defaultHue} on:click={resetHue}>
<div class="text-[var(--btn-content)]">
<Icon icon="fa6-solid:arrow-rotate-left" class="text-[0.875rem]"></Icon>
</div>
</button>
</div>
<div class="flex gap-1">
<div id="hueValue" class="transition bg-[var(--btn-regular-bg)] w-10 h-7 rounded-md flex justify-center
font-bold text-sm items-center text-[var(--btn-content)]">
{hue}
</div>
</div>
</div>
<div class="w-full h-6 px-1 bg-[oklch(0.80_0.10_0)] dark:bg-[oklch(0.70_0.10_0)] rounded select-none">
<input aria-label={i18n(I18nKey.themeColor)} type="range" min="0" max="360" bind:value={hue}
class="slider" id="colorSlider" step="5" style="width: 100%">
</div>
</div>
<style lang="stylus">
#display-setting
input[type="range"]
-webkit-appearance none
height 1.5rem
background-image var(--color-selection-bar)
transition background-image 0.15s ease-in-out
/* Input Thumb */
&::-webkit-slider-thumb
-webkit-appearance none
height 1rem
width 0.5rem
border-radius 0.125rem
background rgba(255, 255, 255, 0.7)
box-shadow none
&:hover
background rgba(255, 255, 255, 0.8)
&:active
background rgba(255, 255, 255, 0.6)
&::-moz-range-thumb
-webkit-appearance none
height 1rem
width 0.5rem
border-radius 0.125rem
border-width 0
background rgba(255, 255, 255, 0.7)
box-shadow none
&:hover
background rgba(255, 255, 255, 0.8)
&:active
background rgba(255, 255, 255, 0.6)
&::-ms-thumb
-webkit-appearance none
height 1rem
width 0.5rem
border-radius 0.125rem
background rgba(255, 255, 255, 0.7)
box-shadow none
&:hover
background rgba(255, 255, 255, 0.8)
&:active
background rgba(255, 255, 255, 0.6)
</style>

View File

@@ -0,0 +1,231 @@
---
import { Icon } from "astro-icon/components";
import { LinkPresets } from "../../constants/link-presets";
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
import { LinkPreset, type NavBarLink } from "../../types/config";
import { url } from "../../utils/url-utils";
interface Props {
link: NavBarLink;
class?: string;
}
const { link, class: className } = Astro.props;
// 国际化标题映射
const navTitleMap: Record<string, string> = {
Links: I18nKey.navLinks,
My: I18nKey.navMy,
About: I18nKey.navAbout,
Others: I18nKey.navOthers,
Anime: I18nKey.anime,
Diary: I18nKey.diary,
Gallery: I18nKey.albums,
Devices: I18nKey.devices,
Projects: I18nKey.projects,
Skills: I18nKey.skills,
Timeline: I18nKey.timeline,
Friends: I18nKey.friends,
};
// 获取国际化的标题
const getLocalizedName = (name: string): string => {
const keyValue = navTitleMap[name];
if (keyValue) {
return i18n(keyValue as I18nKey);
}
return name;
};
// 转换子菜单中的LinkPreset为NavBarLink
const processedLink = {
...link,
children: link.children?.map((child: NavBarLink | LinkPreset): NavBarLink => {
if (typeof child === "number") {
return LinkPresets[child];
}
return child;
}),
};
const hasChildren = processedLink.children && processedLink.children.length > 0;
---
<div class:list={["dropdown-container", className]} data-dropdown>
{hasChildren ? (
<button
class="btn-plain scale-animation rounded-lg h-11 font-bold px-5 active:scale-95 dropdown-trigger"
aria-expanded="false"
aria-haspopup="true"
data-dropdown-trigger
>
<div class="flex items-center">
{processedLink.icon && <Icon name={processedLink.icon} class="text-[1.1rem] mr-2" />}
{getLocalizedName(processedLink.name)}
<Icon name="material-symbols:keyboard-arrow-down-rounded" class="text-[1.25rem] transition-transform duration-200 dropdown-arrow ml-1" />
</div>
</button>
<div class="dropdown-menu" data-dropdown-menu>
<div class="dropdown-content">
{processedLink.children?.map((child) => (
<a
href={child.external ? child.url : url(child.url)}
target={child.external ? "_blank" : null}
class="dropdown-item"
aria-label={child.name}
>
<div class="flex items-center">
{child.icon && <Icon name={child.icon} class="text-[1rem] mr-2" />}
<span>{getLocalizedName(child.name)}</span>
</div>
{child.external && (
<Icon name="fa6-solid:arrow-up-right-from-square" class="text-[0.75rem] text-black/25 dark:text-white/25" />
)}
</a>
))}
</div>
</div>
) : (
<a
aria-label={processedLink.name}
href={processedLink.external ? processedLink.url : url(processedLink.url)}
target={processedLink.external ? "_blank" : null}
class="btn-plain scale-animation rounded-lg h-11 font-bold px-5 active:scale-95"
>
<div class="flex items-center">
{processedLink.icon && <Icon name={processedLink.icon} class="text-[1.1rem] mr-2" />}
{getLocalizedName(processedLink.name)}
{processedLink.external && <Icon name="fa6-solid:arrow-up-right-from-square" class="text-[0.875rem] transition -translate-y-[1px] ml-1 text-black/[0.2] dark:text-white/[0.2]" />}
</div>
</a>
)}
</div>
<style>
.dropdown-container {
@apply relative;
}
.dropdown-menu {
@apply absolute top-full left-0 pt-2 opacity-0 invisible pointer-events-none transition-all duration-200 ease-out transform translate-y-[-8px] z-50;
}
.dropdown-container:hover .dropdown-menu,
.dropdown-container:focus-within .dropdown-menu {
@apply opacity-100 visible pointer-events-auto translate-y-0;
}
.dropdown-container:hover .dropdown-arrow,
.dropdown-container:focus-within .dropdown-arrow {
@apply rotate-180;
}
.dropdown-content {
@apply bg-[var(--float-panel-bg)] rounded-[var(--radius-large)] shadow-xl dark:shadow-none border border-black/5 dark:border-white/10 py-2 min-w-[12rem];
}
.dropdown-item {
@apply flex items-center justify-between px-4 py-2.5 text-black/75 dark:text-white/75 hover:text-[var(--primary)] hover:bg-[var(--btn-plain-bg-hover)] transition-colors duration-150 font-medium;
}
.dropdown-item:first-child {
@apply rounded-t-[calc(var(--radius-large)-0.5rem)];
}
.dropdown-item:last-child {
@apply rounded-b-[calc(var(--radius-large)-0.5rem)];
}
/* 移动端隐藏下拉菜单 */
@media (max-width: 768px) {
.dropdown-container {
@apply hidden;
}
}
</style>
<script>
// 键盘导航支持
document.addEventListener('DOMContentLoaded', function() {
const dropdowns = document.querySelectorAll('[data-dropdown]');
dropdowns.forEach(dropdown => {
const trigger = dropdown.querySelector('[data-dropdown-trigger]') as HTMLElement;
const menu = dropdown.querySelector('[data-dropdown-menu]') as HTMLElement;
const items = dropdown.querySelectorAll('.dropdown-item') as NodeListOf<HTMLElement>;
if (!trigger || !menu) return;
// 键盘事件处理
trigger.addEventListener('keydown', function(e) {
const keyEvent = e as KeyboardEvent;
if (keyEvent.key === 'Enter' || keyEvent.key === ' ') {
e.preventDefault();
toggleDropdown(dropdown, trigger, menu);
} else if (keyEvent.key === 'ArrowDown') {
e.preventDefault();
openDropdown(dropdown, trigger, menu);
if (items.length > 0) {
items[0].focus();
}
} else if (keyEvent.key === 'Escape') {
closeDropdown(dropdown, trigger, menu);
}
});
// 菜单项键盘导航
items.forEach((item, index) => {
item.addEventListener('keydown', function(e) {
const keyEvent = e as KeyboardEvent;
if (keyEvent.key === 'ArrowDown') {
e.preventDefault();
const nextIndex = (index + 1) % items.length;
items[nextIndex].focus();
} else if (keyEvent.key === 'ArrowUp') {
e.preventDefault();
const prevIndex = (index - 1 + items.length) % items.length;
items[prevIndex].focus();
} else if (keyEvent.key === 'Escape') {
closeDropdown(dropdown, trigger, menu);
trigger.focus();
}
});
});
});
// 点击外部关闭下拉菜单
document.addEventListener('click', function(e) {
dropdowns.forEach(dropdown => {
if (!dropdown.contains(e.target as Node)) {
const trigger = dropdown.querySelector('[data-dropdown-trigger]') as HTMLElement;
const menu = dropdown.querySelector('[data-dropdown-menu]') as HTMLElement;
if (trigger && menu) {
closeDropdown(dropdown, trigger, menu);
}
}
});
});
});
function toggleDropdown(dropdown: Element, trigger: HTMLElement, menu: HTMLElement) {
const isOpen = trigger.getAttribute('aria-expanded') === 'true';
if (isOpen) {
closeDropdown(dropdown, trigger, menu);
} else {
openDropdown(dropdown, trigger, menu);
}
}
function openDropdown(_dropdown: Element, trigger: HTMLElement, menu: HTMLElement) {
trigger.setAttribute('aria-expanded', 'true');
menu.classList.remove('opacity-0', 'invisible', 'pointer-events-none', 'translate-y-[-8px]');
menu.classList.add('opacity-100', 'visible', 'pointer-events-auto', 'translate-y-0');
}
function closeDropdown(_dropdown: Element, trigger: HTMLElement, menu: HTMLElement) {
trigger.setAttribute('aria-expanded', 'false');
menu.classList.add('opacity-0', 'invisible', 'pointer-events-none', 'translate-y-[-8px]');
menu.classList.remove('opacity-100', 'visible', 'pointer-events-auto', 'translate-y-0');
}
</script>

View File

@@ -0,0 +1,934 @@
<script lang="ts">
// 导入 Svelte 的生命周期函数和过渡效果
// 导入 Icon 组件,用于显示图标
import Icon from "@iconify/svelte";
import { onDestroy, onMount } from "svelte";
import { slide } from "svelte/transition";
// 从配置文件中导入音乐播放器配置
import { musicPlayerConfig } from "../../config";
// 导入国际化相关的 Key 和 i18n 实例
import Key from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
// 音乐播放器模式,可选 "local" 或 "meting",从本地配置中获取或使用默认值 "meting"
let mode = musicPlayerConfig.mode ?? "meting";
// 声明 Meting API 相关变量(仅在 mode 为 "meting" 时使用)
let meting_api: string;
let meting_id: string;
let meting_server: string;
let meting_type: string;
// 根据模式初始化 Meting API 配置
if (mode === "meting") {
// Meting API 地址,从配置中获取或使用默认地址(bilibili.uno(由哔哩哔哩松坂有希公益管理)),服务器在海外,部分音乐平台可能不支持并且速度可能慢,也可以自建Meting API
meting_api = musicPlayerConfig.meting_api ?? "https://www.bilibili.uno/api?server=:server&type=:type&id=:id&auth=:auth&r=:r";
// Meting API 的 ID从配置中获取或使用默认值
meting_id = musicPlayerConfig.id ?? "14164869977";
// Meting API 的服务器,从配置中获取或使用默认值,有的meting的api源支持更多平台,一般来说,netease=网易云音乐, tencent=QQ音乐, kugou=酷狗音乐, xiami=虾米音乐, baidu=百度音乐
meting_server = musicPlayerConfig.server ?? "netease";
// Meting API 的类型,从配置中获取或使用默认值
meting_type = musicPlayerConfig.type ?? "playlist";
}
// 播放状态,默认为 false (未播放)
let isPlaying = false;
// 播放器是否展开,默认为 false
let isExpanded = false;
// 播放器是否隐藏,默认为 false
let isHidden = false;
// 是否显示播放列表,默认为 false
let showPlaylist = false;
// 当前播放时间,默认为 0
let currentTime = 0;
// 歌曲总时长,默认为 0
let duration = 0;
// 音量,默认为 0.7
let volume = 0.7;
// 是否静音,默认为 false
let isMuted = false;
// 是否正在加载,默认为 false
let isLoading = false;
// 是否随机播放,默认为 false
let isShuffled = false;
// 循环模式0: 不循环, 1: 单曲循环, 2: 列表循环,默认为 0
let isRepeating = 0;
// 错误信息,默认为空字符串
let errorMessage = "";
// 是否显示错误信息,默认为 false
let showError = false;
// 当前歌曲信息
let currentSong = {
title: "Sample Song",
artist: "Sample Artist",
cover: "/favicon/favicon.ico",
url: "",
duration: 0,
};
type Song = {
id: number;
title: string;
artist: string;
cover: string;
url: string;
duration: number;
};
let playlist: Song[] = [];
let currentIndex = 0;
let audio: HTMLAudioElement;
let progressBar: HTMLElement;
let volumeBar: HTMLElement;
// const localPlaylist = [
// {
// id: 1,
// title: "ひとり上手",
// artist: "Kaya",
// cover: "assets/music/cover/hitori.jpg",
// url: "assets/music/url/hitori.mp3",
// duration: 240,
// },
// {
// id: 2,
// title: "眩耀夜行",
// artist: "スリーズブーケ",
// cover: "assets/music/cover/xryx.jpg",
// url: "assets/music/url/xryx.mp3",
// duration: 180,
// },
// {
// id: 3,
// title: "春雷の頃",
// artist: "22/7",
// cover: "assets/music/cover/cl.jpg",
// url: "assets/music/url/cl.mp3",
// duration: 200,
// },
// {
// id: 1,
// title: "下完这场雨",
// artist: "后弦",
// cover: "assets/music/cover/hitori.jpg",
// url: "assets/music/url/xwzcy.mp3",
// duration: 200,
// },
// {
// id: 2,
// title: "紧急联络人",
// artist: "Gareth.T",
// cover: "assets/music/cover/xryx.jpg",
// url: "assets/music/url/jjllr.mp3",
// duration: 200,
// },
// {
// id: 3,
// title: "寂寞寂寞不好",
// artist: "曹格",
// cover: "assets/music/cover/cl.jpg",
// url: "assets/music/url/jmjmbh.mp3",
// duration: 200,
// },
// {
// id: 4,
// title: "习惯失恋",
// artist: "容祖儿",
// cover: "assets/music/cover/cl.jpg",
// url: "assets/music/url/xgsl.mp3",
// duration: 200,
// },
// {
// id: 5,
// title: "想你的夜",
// artist: "关喆",
// cover: "assets/music/cover/cl.jpg",
// url: "assets/music/url/xndy.mp3",
// duration: 200,
// },
// {
// id: 6,
// title: "用背脊唱情歌",
// artist: "Gareth.T",
// cover: "assets/music/cover/cl.jpg",
// url: "assets/music/url/ybjcqg.mp3",
// duration: 200,
// },
// {
// id: 7,
// title: "不该",
// artist: "周杰伦 张惠妹",
// cover: "assets/music/cover/cl.jpg",
// url: "assets/music/url/bg.mp3",
// duration: 200,
// },
// ];
async function loadLocalPlaylist() {
try {
const response = await fetch('/assets/music/url/song.json');
if (!response.ok) throw new Error('Failed to load playlist');
const data = await response.json();
playlist = data;
if (playlist.length > 0) {
loadSong(playlist[0]);
}
} catch (error) {
showErrorMessage("本地歌单加载失败");
}
}
async function fetchMetingPlaylist() {
if (!meting_api || !meting_id) return;
isLoading = true;
const apiUrl = meting_api
.replace(":server", meting_server)
.replace(":type", meting_type)
.replace(":id", meting_id)
.replace(":auth", "")
.replace(":r", Date.now().toString());
try {
const res = await fetch(apiUrl);
if (!res.ok) throw new Error("meting api error");
const list = await res.json();
playlist = list.map((song) => {
let title = song.name ?? song.title ?? "未知歌曲";
let artist = song.artist ?? song.author ?? "未知艺术家";
let dur = song.duration ?? 0;
if (dur > 10000) dur = Math.floor(dur / 1000);
if (!Number.isFinite(dur) || dur <= 0) dur = 0;
return {
id: song.id,
title,
artist,
cover: song.pic ?? "",
url: song.url ?? "",
duration: dur,
};
});
if (playlist.length > 0) {
loadSong(playlist[0]);
}
isLoading = false;
} catch (e) {
showErrorMessage("Meting 歌单获取失败");
isLoading = false;
}
}
function togglePlay() {
if (!audio || !currentSong.url) return;
if (isPlaying) {
audio.pause();
} else {
audio.play();
}
}
function toggleExpanded() {
isExpanded = !isExpanded;
if (isExpanded) {
showPlaylist = false;
isHidden = false;
}
}
function toggleHidden() {
isHidden = !isHidden;
if (isHidden) {
isExpanded = false;
showPlaylist = false;
}
}
function togglePlaylist() {
showPlaylist = !showPlaylist;
}
function toggleShuffle() {
isShuffled = !isShuffled;
}
function toggleRepeat() {
isRepeating = (isRepeating + 1) % 3;
}
function previousSong() {
if (playlist.length <= 1) return;
const newIndex = currentIndex > 0 ? currentIndex - 1 : playlist.length - 1;
playSong(newIndex);
}
function nextSong() {
if (playlist.length <= 1) return;
let newIndex: number;
if (isShuffled) {
do {
newIndex = Math.floor(Math.random() * playlist.length);
} while (newIndex === currentIndex && playlist.length > 1);
} else {
newIndex = currentIndex < playlist.length - 1 ? currentIndex + 1 : 0;
}
playSong(newIndex);
}
function playSong(index: number) {
if (index < 0 || index >= playlist.length) return;
const wasPlaying = isPlaying;
currentIndex = index;
if (audio) audio.pause();
loadSong(playlist[currentIndex]);
if (wasPlaying || !isPlaying) {
setTimeout(() => {
if (!audio) return;
if (audio.readyState >= 2) {
audio.play().catch(() => {});
} else {
audio.addEventListener(
"canplay",
() => {
audio.play().catch(() => {});
},
{ once: true },
);
}
}, 100);
}
}
function getAssetPath(path: string): string {
if (path.startsWith("http://") || path.startsWith("https://")) return path;
if (path.startsWith("/")) return path;
return `/${path}`;
}
function loadSong(song: typeof currentSong) {
if (!song || !audio) return;
currentSong = { ...song };
if (song.url) {
isLoading = true;
audio.pause();
audio.currentTime = 0;
currentTime = 0;
duration = song.duration ?? 0;
audio.removeEventListener("loadeddata", handleLoadSuccess);
audio.removeEventListener("error", handleLoadError);
audio.removeEventListener("loadstart", handleLoadStart);
audio.addEventListener("loadeddata", handleLoadSuccess, { once: true });
audio.addEventListener("error", handleLoadError, { once: true });
audio.addEventListener("loadstart", handleLoadStart, { once: true });
audio.src = getAssetPath(song.url);
audio.load();
} else {
isLoading = false;
}
}
function handleLoadSuccess() {
isLoading = false;
if (audio?.duration && audio.duration > 1) {
duration = Math.floor(audio.duration);
if (playlist[currentIndex]) playlist[currentIndex].duration = duration;
currentSong.duration = duration;
}
}
function handleLoadError(_event: Event) {
isLoading = false;
showErrorMessage(`无法播放 "${currentSong.title}",正在尝试下一首...`);
if (playlist.length > 1) setTimeout(() => nextSong(), 1000);
else showErrorMessage("播放列表中没有可用的歌曲");
}
function handleLoadStart() {}
function showErrorMessage(message: string) {
errorMessage = message;
showError = true;
setTimeout(() => {
showError = false;
}, 3000);
}
function hideError() {
showError = false;
}
function setProgress(event: MouseEvent) {
if (!audio || !progressBar) return;
const rect = progressBar.getBoundingClientRect();
const percent = (event.clientX - rect.left) / rect.width;
const newTime = percent * duration;
audio.currentTime = newTime;
currentTime = newTime;
}
function setVolume(event: MouseEvent) {
if (!audio || !volumeBar) return;
const rect = volumeBar.getBoundingClientRect();
const percent = Math.max(
0,
Math.min(1, (event.clientX - rect.left) / rect.width),
);
volume = percent;
audio.volume = volume;
isMuted = volume === 0;
}
function toggleMute() {
if (!audio) return;
isMuted = !isMuted;
audio.muted = isMuted;
}
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function handleAudioEvents() {
if (!audio) return;
audio.addEventListener("play", () => {
isPlaying = true;
});
audio.addEventListener("pause", () => {
isPlaying = false;
});
audio.addEventListener("timeupdate", () => {
currentTime = audio.currentTime;
});
audio.addEventListener("ended", () => {
if (isRepeating === 1) {
audio.currentTime = 0;
audio.play().catch(() => {});
} else if (
isRepeating === 2 ||
currentIndex < playlist.length - 1 ||
isShuffled
) {
nextSong();
} else {
isPlaying = false;
}
});
audio.addEventListener("error", (_event) => {
isLoading = false;
});
audio.addEventListener("stalled", () => {});
audio.addEventListener("waiting", () => {});
}
onMount(() => {
audio = new Audio();
audio.volume = volume;
handleAudioEvents();
if (!musicPlayerConfig.enable) {
return;
}
if (mode === "meting") {
fetchMetingPlaylist();
} else {
loadLocalPlaylist();
}
});
// onMount(() => {
// audio = new Audio();
// audio.volume = volume;
// handleAudioEvents();
// if (!musicPlayerConfig.enable) {
// return;
// }
// if (mode === "meting") {
// fetchMetingPlaylist();
// } else {
// // 使用本地播放列表不发送任何API请求
// playlist = [...localPlaylist];
// if (playlist.length > 0) {
// loadSong(playlist[0]);
// } else {
// showErrorMessage("本地播放列表为空");
// }
// }
// });
onDestroy(() => {
if (audio) {
audio.pause();
audio.src = "";
}
});
</script>
{#if musicPlayerConfig.enable}
{#if showError}
<div class="fixed bottom-20 right-4 z-[60] max-w-sm">
<div class="bg-red-500 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 animate-slide-up">
<Icon icon="material-symbols:error" class="text-xl flex-shrink-0" />
<span class="text-sm flex-1">{errorMessage}</span>
<button on:click={hideError} class="text-white/80 hover:text-white transition-colors">
<Icon icon="material-symbols:close" class="text-lg" />
</button>
</div>
</div>
{/if}
<div class="music-player fixed bottom-4 right-4 z-50 transition-all duration-300 ease-in-out"
class:expanded={isExpanded}
class:hidden-mode={isHidden}>
<!-- 隐藏状态的小圆球 -->
<div class="orb-player w-12 h-12 bg-[var(--primary)] rounded-full shadow-lg cursor-pointer transition-all duration-500 ease-in-out flex items-center justify-center hover:scale-110 active:scale-95"
class:opacity-0={!isHidden}
class:scale-0={!isHidden}
class:pointer-events-none={!isHidden}
on:click={toggleHidden}
on:keydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleHidden();
}
}}
role="button"
tabindex="0"
aria-label="显示音乐播放器">
{#if isLoading}
<Icon icon="eos-icons:loading" class="text-white text-lg" />
{:else if isPlaying}
<div class="flex space-x-0.5">
<div class="w-0.5 h-3 bg-white rounded-full animate-pulse"></div>
<div class="w-0.5 h-4 bg-white rounded-full animate-pulse" style="animation-delay: 150ms;"></div>
<div class="w-0.5 h-2 bg-white rounded-full animate-pulse" style="animation-delay: 300ms;"></div>
</div>
{:else}
<Icon icon="material-symbols:music-note" class="text-white text-lg" />
{/if}
</div>
<!-- 收缩状态的迷你播放器(封面圆形) -->
<div class="mini-player card-base bg-[var(--float-panel-bg)] shadow-xl rounded-2xl p-3 transition-all duration-500 ease-in-out"
class:opacity-0={isExpanded || isHidden}
class:scale-95={isExpanded || isHidden}
class:pointer-events-none={isExpanded || isHidden}>
<div class="flex items-center gap-3">
<!-- 封面区域:点击控制播放/暂停 -->
<div class="cover-container relative w-12 h-12 rounded-full overflow-hidden cursor-pointer"
on:click={togglePlay}
on:keydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePlay();
}
}}
role="button"
tabindex="0"
aria-label={isPlaying ? '暂停' : '播放'}>
<img src={getAssetPath(currentSong.cover)} alt="封面"
class="w-full h-full object-cover transition-transform duration-300"
class:spinning={isPlaying && !isLoading}
class:animate-pulse={isLoading} />
<div class="absolute inset-0 bg-black/20 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity">
{#if isLoading}
<Icon icon="eos-icons:loading" class="text-white text-xl" />
{:else if isPlaying}
<Icon icon="material-symbols:pause" class="text-white text-xl" />
{:else}
<Icon icon="material-symbols:play-arrow" class="text-white text-xl" />
{/if}
</div>
</div>
<!-- 歌曲信息区域:点击展开播放器 -->
<div class="flex-1 min-w-0 cursor-pointer"
on:click={toggleExpanded}
on:keydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpanded();
}
}}
role="button"
tabindex="0"
aria-label="展开音乐播放器">
<div class="text-sm font-medium text-90 truncate">{currentSong.title}</div>
<div class="text-xs text-50 truncate">{currentSong.artist}</div>
</div>
<div class="flex items-center gap-1">
<button class="btn-plain w-8 h-8 rounded-lg flex items-center justify-center"
on:click|stopPropagation={toggleHidden}
title="隐藏播放器">
<Icon icon="material-symbols:visibility-off" class="text-lg" />
</button>
<button class="btn-plain w-8 h-8 rounded-lg flex items-center justify-center"
on:click|stopPropagation={toggleExpanded}>
<Icon icon="material-symbols:expand-less" class="text-lg" />
</button>
</div>
</div>
</div>
<!-- 展开状态的完整播放器(封面圆形) -->
<div class="expanded-player card-base bg-[var(--float-panel-bg)] shadow-xl rounded-2xl p-4 transition-all duration-500 ease-in-out"
class:opacity-0={!isExpanded}
class:scale-95={!isExpanded}
class:pointer-events-none={!isExpanded}>
<div class="flex items-center gap-4 mb-4">
<div class="cover-container relative w-16 h-16 rounded-full overflow-hidden flex-shrink-0">
<img src={getAssetPath(currentSong.cover)} alt="封面"
class="w-full h-full object-cover transition-transform duration-300"
class:spinning={isPlaying && !isLoading}
class:animate-pulse={isLoading} />
</div>
<div class="flex-1 min-w-0">
<div class="song-title text-lg font-bold text-90 truncate mb-1">{currentSong.title}</div>
<div class="song-artist text-sm text-50 truncate">{currentSong.artist}</div>
<div class="text-xs text-30 mt-1">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
<div class="flex items-center gap-1">
<button class="btn-plain w-8 h-8 rounded-lg flex items-center justify-center"
on:click={toggleHidden}
title="隐藏播放器">
<Icon icon="material-symbols:visibility-off" class="text-lg" />
</button>
<button class="btn-plain w-8 h-8 rounded-lg flex items-center justify-center"
on:click={toggleExpanded}>
<Icon icon="material-symbols:expand-more" class="text-lg" />
</button>
</div>
</div>
<div class="progress-section mb-4">
<div class="progress-bar flex-1 h-2 bg-[var(--btn-regular-bg)] rounded-full cursor-pointer"
bind:this={progressBar}
on:click={setProgress}
on:keydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const rect = progressBar.getBoundingClientRect();
const percent = 0.5;
const newTime = percent * duration;
if (audio) {
audio.currentTime = newTime;
currentTime = newTime;
}
}
}}
role="slider"
tabindex="0"
aria-label="播放进度"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={duration > 0 ? (currentTime / duration * 100) : 0}>
<div class="h-full bg-[var(--primary)] rounded-full transition-all duration-100"
style="width: {duration > 0 ? (currentTime / duration) * 100 : 0}%"></div>
</div>
</div>
<div class="controls flex items-center justify-center gap-2 mb-4">
<!-- 随机按钮高亮 -->
<button class="w-10 h-10 rounded-lg"
class:btn-regular={isShuffled}
class:btn-plain={!isShuffled}
on:click={toggleShuffle}
disabled={playlist.length <= 1}>
<Icon icon="material-symbols:shuffle" class="text-lg" />
</button>
<button class="btn-plain w-10 h-10 rounded-lg" on:click={previousSong}
disabled={playlist.length <= 1}>
<Icon icon="material-symbols:skip-previous" class="text-xl" />
</button>
<button class="btn-regular w-12 h-12 rounded-full"
class:opacity-50={isLoading}
disabled={isLoading}
on:click={togglePlay}>
{#if isLoading}
<Icon icon="eos-icons:loading" class="text-xl" />
{:else if isPlaying}
<Icon icon="material-symbols:pause" class="text-xl" />
{:else}
<Icon icon="material-symbols:play-arrow" class="text-xl" />
{/if}
</button>
<button class="btn-plain w-10 h-10 rounded-lg" on:click={nextSong}
disabled={playlist.length <= 1}>
<Icon icon="material-symbols:skip-next" class="text-xl" />
</button>
<!-- 循环按钮高亮 -->
<button class="w-10 h-10 rounded-lg"
class:btn-regular={isRepeating > 0}
class:btn-plain={isRepeating === 0}
on:click={toggleRepeat}>
{#if isRepeating === 1}
<Icon icon="material-symbols:repeat-one" class="text-lg" />
{:else if isRepeating === 2}
<Icon icon="material-symbols:repeat" class="text-lg" />
{:else}
<Icon icon="material-symbols:repeat" class="text-lg opacity-50" />
{/if}
</button>
</div>
<div class="bottom-controls flex items-center gap-2">
<button class="btn-plain w-8 h-8 rounded-lg" on:click={toggleMute}>
{#if isMuted || volume === 0}
<Icon icon="material-symbols:volume-off" class="text-lg" />
{:else if volume < 0.5}
<Icon icon="material-symbols:volume-down" class="text-lg" />
{:else}
<Icon icon="material-symbols:volume-up" class="text-lg" />
{/if}
</button>
<div class="flex-1 h-2 bg-[var(--btn-regular-bg)] rounded-full cursor-pointer"
bind:this={volumeBar}
on:click={setVolume}
on:keydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (e.key === 'Enter') toggleMute();
}
}}
role="slider"
tabindex="0"
aria-label="音量控制"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={volume * 100}>
<div class="h-full bg-[var(--primary)] rounded-full transition-all duration-100"
style="width: {volume * 100}%"></div>
</div>
<button class="btn-plain w-8 h-8 rounded-lg"
class:text-[var(--primary)]={showPlaylist}
on:click={togglePlaylist}>
<Icon icon="material-symbols:queue-music" class="text-lg" />
</button>
</div>
</div>
{#if showPlaylist}
<div class="playlist-panel float-panel fixed bottom-20 right-4 w-80 max-h-96 overflow-hidden z-50"
transition:slide={{ duration: 300, axis: 'y' }}>
<div class="playlist-header flex items-center justify-between p-4 border-b border-[var(--line-divider)]">
<h3 class="text-lg font-semibold text-90">{i18n(Key.playlist)}</h3>
<button class="btn-plain w-8 h-8 rounded-lg" on:click={togglePlaylist}>
<Icon icon="material-symbols:close" class="text-lg" />
</button>
</div>
<div class="playlist-content overflow-y-auto max-h-80">
{#each playlist as song, index}
<div class="playlist-item flex items-center gap-3 p-3 hover:bg-[var(--btn-plain-bg-hover)] cursor-pointer transition-colors"
class:bg-[var(--btn-plain-bg)]={index === currentIndex}
class:text-[var(--primary)]={index === currentIndex}
on:click={() => playSong(index)}
on:keydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
playSong(index);
}
}}
role="button"
tabindex="0"
aria-label="播放 {song.title} - {song.artist}">
<div class="w-6 h-6 flex items-center justify-center">
{#if index === currentIndex && isPlaying}
<Icon icon="material-symbols:graphic-eq" class="text-[var(--primary)] animate-pulse" />
{:else if index === currentIndex}
<Icon icon="material-symbols:pause" class="text-[var(--primary)]" />
{:else}
<span class="text-sm text-[var(--content-meta)]">{index + 1}</span>
{/if}
</div>
<!-- 歌单列表内封面仍为圆角矩形 -->
<div class="w-10 h-10 rounded-lg overflow-hidden bg-[var(--btn-regular-bg)] flex-shrink-0">
<img src={getAssetPath(song.cover)} alt={song.title} class="w-full h-full object-cover" />
</div>
<div class="flex-1 min-w-0">
<div class="font-medium truncate" class:text-[var(--primary)]={index === currentIndex} class:text-90={index !== currentIndex}>
{song.title}
</div>
<div class="text-sm text-[var(--content-meta)] truncate" class:text-[var(--primary)]={index === currentIndex}>
{song.artist}
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
<style>
.orb-player {
position: relative;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.orb-player::before {
content: '';
position: absolute;
inset: -2px;
background: linear-gradient(45deg, var(--primary), transparent, var(--primary));
border-radius: 50%;
z-index: -1;
opacity: 0;
transition: opacity 0.3s ease;
}
.orb-player:hover::before {
opacity: 0.3;
animation: rotate 2s linear infinite;
}
.orb-player .animate-pulse {
animation: musicWave 1.5s ease-in-out infinite;
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes musicWave {
0%, 100% { transform: scaleY(0.5); }
50% { transform: scaleY(1); }
}
.music-player.hidden-mode {
width: 48px;
height: 48px;
}
.music-player {
max-width: 320px;
user-select: none;
}
.mini-player {
width: 280px;
position: absolute;
bottom: 0;
right: 0;
/*left: 0;*/
}
.expanded-player {
width: 320px;
position: absolute;
bottom: 0;
right: 0;
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.progress-section div:hover,
.bottom-controls > div:hover {
transform: scaleY(1.2);
transition: transform 0.2s ease;
}
@media (max-width: 768px) {
.music-player {
max-width: 280px;
/*left: 8px !important;*/
bottom: 8px !important;
right: 8px !important;
}
.music-player.expanded {
width: calc(100vw - 16px);
max-width: none;
/*left: 8px !important;*/
right: 8px !important;
}
.playlist-panel {
width: calc(100vw - 16px) !important;
/*left: 8px !important;*/
right: 8px !important;
max-width: none;
}
.controls {
gap: 8px;
}
.controls button {
width: 36px;
height: 36px;
}
.controls button:nth-child(3) {
width: 44px;
height: 44px;
}
}
@media (max-width: 480px) {
.music-player {
max-width: 260px;
}
.song-title {
font-size: 14px;
}
.song-artist {
font-size: 12px;
}
.controls {
gap: 6px;
margin-bottom: 12px;
}
.controls button {
width: 32px;
height: 32px;
}
.controls button:nth-child(3) {
width: 40px;
height: 40px;
}
.playlist-item {
padding: 8px 12px;
}
.playlist-item .w-10 {
width: 32px;
height: 32px;
}
}
@keyframes slide-up {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
@media (hover: none) and (pointer: coarse) {
.music-player button,
.playlist-item {
min-height: 44px;
}
.progress-section > div,
.bottom-controls > div:nth-child(2) {
height: 12px;
}
}
/* 自定义旋转动画,停止时保持当前位置 */
@keyframes spin-continuous {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.cover-container img {
animation: spin-continuous 3s linear infinite;
animation-play-state: paused;
}
.cover-container img.spinning {
animation-play-state: running;
}
/* 让主题色按钮更有视觉反馈 */
button.bg-\[var\(--primary\)\] {
box-shadow: 0 0 0 2px var(--primary);
border: none;
}
</style>
{/if}

View File

@@ -0,0 +1,164 @@
---
import { Icon } from "astro-icon/components";
import { LinkPresets } from "../../constants/link-presets";
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
import { LinkPreset, type NavBarLink } from "../../types/config";
import { url } from "../../utils/url-utils";
interface Props {
links: NavBarLink[];
}
// 国际化标题映射
const navTitleMap: Record<string, string> = {
Links: I18nKey.navLinks,
My: I18nKey.navMy,
About: I18nKey.navAbout,
Others: I18nKey.navOthers,
Anime: I18nKey.anime,
Diary: I18nKey.diary,
Gallery: I18nKey.albums,
Devices: I18nKey.devices,
Projects: I18nKey.projects,
Skills: I18nKey.skills,
Timeline: I18nKey.timeline,
Friends: I18nKey.friends,
};
// 获取国际化的标题
const getLocalizedName = (name: string): string => {
const keyValue = navTitleMap[name];
if (keyValue) {
return i18n(keyValue as I18nKey);
}
return name;
};
type ProcessedNavBarLink = Omit<NavBarLink, "children"> & {
children?: ProcessedNavBarLink[];
};
// 处理links中的LinkPreset转换
const processedLinks: ProcessedNavBarLink[] = Astro.props.links.map(
(link: NavBarLink): ProcessedNavBarLink => ({
...link,
children: link.children?.map(
(child: NavBarLink | LinkPreset): ProcessedNavBarLink => {
if (typeof child === "number") {
return LinkPresets[child] as ProcessedNavBarLink;
}
return child as ProcessedNavBarLink;
},
),
}),
);
---
<div id="nav-menu-panel" class:list={["float-panel float-panel-closed absolute transition-all fixed right-4 px-2 py-2 max-h-[80vh] overflow-y-auto"]}>
{processedLinks.map((link) => (
<div class="mobile-menu-item">
{link.children && link.children.length > 0 ? (
<!-- 有子菜单的项目 -->
<div class="mobile-dropdown" data-mobile-dropdown>
<button
class="group flex justify-between items-center py-2 pl-3 pr-1 rounded-lg gap-8 w-full text-left
hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)] transition"
data-mobile-dropdown-trigger
aria-expanded="false"
>
<div class="flex items-center transition text-black/75 dark:text-white/75 font-bold group-hover:text-[var(--primary)] group-active:text-[var(--primary)]">
{link.icon && <Icon name={link.icon} class="text-[1.1rem] mr-2" />}
{getLocalizedName(link.name)}
</div>
<Icon name="material-symbols:keyboard-arrow-down-rounded"
class="transition text-[1.25rem] text-[var(--primary)] mobile-dropdown-arrow duration-200"
/>
</button>
<div class="mobile-submenu" data-mobile-submenu>
{link.children.map((child) => (
<a href={child.external ? child.url : url(child.url)}
class="group flex justify-between items-center py-2 pl-6 pr-1 rounded-lg gap-8
hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)] transition"
target={child.external ? "_blank" : null}
>
<div class="flex items-center transition text-black/60 dark:text-white/60 font-medium group-hover:text-[var(--primary)] group-active:text-[var(--primary)]">
{child.icon && <Icon name={child.icon} class="text-[1.1rem] mr-2" />}
{getLocalizedName(child.name)}
</div>
{child.external && <Icon name="fa6-solid:arrow-up-right-from-square"
class="transition text-[0.75rem] text-black/25 dark:text-white/25 -translate-x-1"
/>}
</a>
))}
</div>
</div>
) : (
<!-- 普通链接项目 -->
<a href={link.external ? link.url : url(link.url)} class="group flex justify-between items-center py-2 pl-3 pr-1 rounded-lg gap-8
hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)] transition
"
target={link.external ? "_blank" : null}
>
<div class="flex items-center transition text-black/75 dark:text-white/75 font-bold group-hover:text-[var(--primary)] group-active:text-[var(--primary)]">
{link.icon && <Icon name={link.icon} class="text-[1.1rem] mr-2" />}
{getLocalizedName(link.name)}
</div>
{!link.external && <Icon name="material-symbols:chevron-right-rounded"
class="transition text-[1.25rem] text-[var(--primary)]"
/>}
{link.external && <Icon name="fa6-solid:arrow-up-right-from-square"
class="transition text-[0.75rem] text-black/25 dark:text-white/25 -translate-x-1"
/>}
</a>
)}
</div>
))}
</div>
<style>
.mobile-submenu {
@apply max-h-0 overflow-hidden transition-all duration-300 ease-in-out;
}
.mobile-dropdown[data-expanded="true"] .mobile-submenu {
@apply max-h-96;
}
.mobile-dropdown[data-expanded="true"] .mobile-dropdown-arrow {
@apply rotate-180;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const mobileDropdowns = document.querySelectorAll('[data-mobile-dropdown]');
mobileDropdowns.forEach(dropdown => {
const trigger = dropdown.querySelector('[data-mobile-dropdown-trigger]');
const submenu = dropdown.querySelector('[data-mobile-submenu]');
if (!trigger || !submenu) return;
trigger.addEventListener('click', function(e) {
e.preventDefault();
const isExpanded = dropdown.getAttribute('data-expanded') === 'true';
// 关闭其他打开的下拉菜单
mobileDropdowns.forEach(otherDropdown => {
if (otherDropdown !== dropdown) {
otherDropdown.setAttribute('data-expanded', 'false');
const otherTrigger = otherDropdown.querySelector('[data-mobile-dropdown-trigger]');
if (otherTrigger) {
otherTrigger.setAttribute('aria-expanded', 'false');
}
}
});
// 切换当前下拉菜单
const newState = !isExpanded;
dropdown.setAttribute('data-expanded', newState.toString());
trigger.setAttribute('aria-expanded', newState.toString());
});
});
});
</script>

View File

@@ -0,0 +1,109 @@
<script>
import { onDestroy, onMount } from "svelte";
import { pioConfig } from "@/config";
// 将配置转换为 Pio 插件需要的格式
const pioOptions = {
mode: pioConfig.mode,
hidden: pioConfig.hiddenOnMobile,
content: pioConfig.dialog || {},
model: pioConfig.models || ["/pio/models/pio/model.json"],
};
// 全局Pio实例引用
let pioInstance = null;
let pioInitialized = false;
let pioContainer;
let pioCanvas;
// 样式已通过 Layout.astro 静态引入,无需动态加载
// 等待 DOM 加载完成后再初始化 Pio
function initPio() {
if (typeof window !== "undefined" && typeof Paul_Pio !== "undefined") {
try {
// 确保DOM元素存在
if (pioContainer && pioCanvas && !pioInitialized) {
pioInstance = new Paul_Pio(pioOptions);
pioInitialized = true;
console.log("Pio initialized successfully (Svelte)");
} else if (!pioContainer || !pioCanvas) {
console.warn("Pio DOM elements not found, retrying...");
setTimeout(initPio, 100);
}
} catch (e) {
console.error("Pio initialization error:", e);
}
} else {
// 如果 Paul_Pio 还未定义,稍后再试
setTimeout(initPio, 100);
}
}
// 样式已通过 Layout.astro 静态引入,无需动态加载函数
// 加载必要的脚本
function loadPioAssets() {
if (typeof window === "undefined") return;
// 样式已通过 Layout.astro 静态引入
// 加载JS脚本
const loadScript = (src, id) => {
return new Promise((resolve, reject) => {
if (document.querySelector(`#${id}`)) {
resolve();
return;
}
const script = document.createElement("script");
script.id = id;
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
};
// 按顺序加载脚本
loadScript("/pio/static/l2d.js", "pio-l2d-script")
.then(() => loadScript("/pio/static/pio.js", "pio-main-script"))
.then(() => {
// 脚本加载完成后初始化
setTimeout(initPio, 100);
})
.catch((error) => {
console.error("Failed to load Pio scripts:", error);
});
}
// 样式已通过 Layout.astro 静态引入,无需页面切换监听
onMount(() => {
if (!pioConfig.enable) return;
// 加载资源并初始化
loadPioAssets();
});
onDestroy(() => {
// Svelte 组件销毁时不需要清理 Pio 实例
// 因为我们希望它在页面切换时保持状态
console.log("Pio Svelte component destroyed (keeping instance alive)");
});
</script>
{#if pioConfig.enable}
<div class={`pio-container ${pioConfig.position || 'right'}`} bind:this={pioContainer}>
<div class="pio-action"></div>
<canvas
id="pio"
bind:this={pioCanvas}
width={pioConfig.width || 280}
height={pioConfig.height || 250}
></canvas>
</div>
{/if}
<style>
/* Pio 相关样式将通过外部CSS文件加载 */
</style>

View File

@@ -0,0 +1,108 @@
---
import { Icon } from "astro-icon/components";
import { profileConfig, umamiConfig } from "../../config";
import { url } from "../../utils/url-utils";
import ImageWrapper from "../misc/ImageWrapper.astro";
import TypewriterText from "../TypewriterText.astro";
// 解析 umami
const umamiEnabled = umamiConfig.enabled || false;
const umamiWebsiteId =
umamiConfig.scripts.match(/data-website-id="([^"]+)"/)?.[1] || "";
const umamiApiKey = umamiConfig.apiKey || "";
const umamiBaseUrl = umamiConfig.baseUrl || "";
---
<div class="card-base p-3">
<a aria-label="Go to About Page" href={url('/about/')}
class="group block relative mx-auto mt-1 lg:mx-0 lg:mt-0 mb-3
max-w-[12rem] lg:max-w-none overflow-hidden rounded-xl active:scale-95">
<div class="absolute transition pointer-events-none group-hover:bg-black/30 group-active:bg-black/50
w-full h-full z-50 flex items-center justify-center">
<Icon name="fa6-regular:address-card"
class="transition opacity-0 scale-90 group-hover:scale-100 group-hover:opacity-100 text-white text-5xl">
</Icon>
</div>
<ImageWrapper src={profileConfig.avatar || ""} alt="Profile Image of the Author" class="mx-auto lg:w-full h-full lg:mt-0 "></ImageWrapper>
</a>
<div class="px-2">
<div class="font-bold text-xl text-center mb-1 dark:text-neutral-50 transition">{profileConfig.name}</div>
<div class="h-1 w-5 bg-[var(--primary)] mx-auto rounded-full mb-2 transition"></div>
<div class="text-center text-neutral-400 mb-2.5 transition">
{profileConfig.typewriter?.enable ? (
<TypewriterText
text={profileConfig.bio || ""}
speed={profileConfig.typewriter.speed}
class="inline-block"
/>
) : (
profileConfig.bio
)}
</div>
<div class="flex gap-2 justify-center mb-1">
{profileConfig.links.length > 1 && profileConfig.links.map(item =>
<a rel="me" aria-label={item.name} href={item.url} target="_blank" class="btn-regular rounded-lg h-10 w-10 active:scale-90">
<Icon name={item.icon} class="text-[1.5rem]"></Icon>
</a>
)}
{profileConfig.links.length == 1 && <a rel="me" aria-label={profileConfig.links[0].name} href={profileConfig.links[0].url} target="_blank"
class="btn-regular rounded-lg h-10 gap-2 px-3 font-bold active:scale-95">
<Icon name={profileConfig.links[0].icon} class="text-[1.5rem]"></Icon>
{profileConfig.links[0].name}
</a>}
</div>
{umamiEnabled && (
<hr class="my-2 border-t border-dashed border-gray-300 dark:border-gray-700" />
<div class="text-sm text-gray-500 mt-2 text-center">
<Icon name="fa6-solid:eye" class="inline-block mr-1 text-gray-400 text-sm align-middle" />
<span id="site-stats-display">统计加载中...</span>
</div>
)}
</div>
</div>
{umamiEnabled && (
<script is:inline define:vars={{ umamiBaseUrl, umamiApiKey, umamiWebsiteId, umamiConfig }}>
// 客户端统计文案生成函数
function generateStatsText(pageViews, visits) {
return `浏览量 ${pageViews} · 访问次数 ${visits}`;
}
// 获取访问量统计
async function fetchSiteStats(isRetry = false) {
if (!umamiConfig.enabled || !isRetry) {
// isRetry is used to avoid recursive retry loop or similar logic if implemented
}
if (!umamiConfig.enabled) {
return;
}
try {
// 调用全局工具获取 Umami 统计数据
const stats = await getUmamiWebsiteStats(umamiBaseUrl, umamiApiKey, umamiWebsiteId);
// 从返回的数据中提取页面浏览量和访客数
const pageViews = stats.pageviews || 0;
const visits = stats.visits || 0;
const displayElement = document.getElementById('site-stats-display');
if (displayElement) {
displayElement.textContent = generateStatsText(pageViews, visits);
}
} catch (error) {
console.error('Error fetching site stats:', error);
const displayElement = document.getElementById('site-stats-display');
if (displayElement) {
displayElement.textContent = '统计不可用';
}
}
}
// 页面加载完成后获取统计数据
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fetchSiteStats);
} else {
fetchSiteStats();
}
</script>
)}

View File

@@ -0,0 +1,216 @@
---
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
export interface Props {
project: {
id: string;
title: string;
description: string;
image?: string;
category: string;
techStack: string[];
status: "completed" | "in-progress" | "planned";
demoUrl?: string;
sourceUrl?: string;
startDate: string;
endDate?: string;
featured?: boolean;
tags?: string[];
};
size?: "small" | "medium" | "large";
showImage?: boolean;
maxTechStack?: number;
}
const {
project,
size = "medium",
showImage = true,
maxTechStack = 4,
} = Astro.props;
// 状态样式映射
const getStatusStyle = (status: string) => {
switch (status) {
case "completed":
return "bg-green-600/20 text-green-700 dark:bg-green-900/30 dark:text-green-400";
case "in-progress":
return "bg-yellow-600/20 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400";
case "planned":
return "bg-gray-600/20 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400";
default:
return "bg-gray-600/20 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400";
}
};
// 状态文本映射
const getStatusText = (status: string) => {
switch (status) {
case "completed":
return i18n(I18nKey.projectsCompleted);
case "in-progress":
return i18n(I18nKey.projectsInProgress);
case "planned":
return i18n(I18nKey.projectsPlanned);
default:
return status;
}
};
// 尺寸样式映射
const getSizeClasses = (size: string) => {
switch (size) {
case "small":
return {
container: "p-4",
title: "text-lg",
description: "text-sm line-clamp-2",
tech: "text-xs",
links: "text-sm",
};
case "large":
return {
container: "p-6",
title: "text-xl",
description: "text-base line-clamp-3",
tech: "text-sm",
links: "text-base",
};
default: // medium
return {
container: "p-5",
title: "text-lg",
description: "text-sm line-clamp-2",
tech: "text-xs",
links: "text-sm",
};
}
};
const sizeClasses = getSizeClasses(size);
---
<div class="bg-transparent rounded-lg border border-black/10 dark:border-white/10 overflow-hidden hover:shadow-lg transition-all duration-300 group">
<!-- 项目图片 -->
{showImage && project.image && (
<div class={`overflow-hidden ${size === 'large' ? 'aspect-video' : 'aspect-[4/3]'}`}>
<img
src={project.image}
alt={project.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
</div>
)}
<!-- 项目内容 -->
<div class={sizeClasses.container}>
<!-- 标题和状态 -->
<div class="flex items-start justify-between mb-3">
<h3 class={`font-semibold text-black/90 dark:text-white/90 ${sizeClasses.title} ${size === 'small' ? 'line-clamp-1' : ''}`}>
{project.title}
</h3>
<span class={`px-2 py-1 text-xs rounded-full shrink-0 ml-2 ${getStatusStyle(project.status)}`}>
{getStatusText(project.status)}
</span>
</div>
<!-- 分类标签 -->
{project.category && (
<div class="mb-2">
<span class="px-2 py-1 text-xs bg-blue-600/20 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400 rounded">
{project.category}
</span>
</div>
)}
<!-- 项目描述 -->
<p class={`text-black/60 dark:text-white/60 mb-4 ${sizeClasses.description}`}>
{project.description}
</p>
<!-- 技术栈 -->
{project.techStack && project.techStack.length > 0 && (
<div class="flex flex-wrap gap-1 mb-4">
{project.techStack.slice(0, maxTechStack).map((tech) => (
<span class={`px-2 py-1 bg-gray-600/20 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded ${sizeClasses.tech}`}>
{tech}
</span>
))}
{project.techStack.length > maxTechStack && (
<span class={`px-2 py-1 bg-gray-600/20 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400 rounded ${sizeClasses.tech}`}>
+{project.techStack.length - maxTechStack}
</span>
)}
</div>
)}
<!-- 标签 -->
{project.tags && project.tags.length > 0 && (
<div class="flex flex-wrap gap-1 mb-4">
{project.tags.map((tag) => (
<span class={`px-2 py-1 bg-purple-600/20 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400 rounded ${sizeClasses.tech}`}>
#{tag}
</span>
))}
</div>
)}
<!-- 链接 -->
<div class="flex gap-3">
{project.demoUrl && (
<a
href={project.demoUrl}
target="_blank"
rel="noopener noreferrer"
class={`text-blue-600 dark:text-blue-400 hover:underline font-medium transition-colors ${sizeClasses.links}`}
>
{i18n(I18nKey.projectsDemo)}
</a>
)}
{project.sourceUrl && (
<a
href={project.sourceUrl}
target="_blank"
rel="noopener noreferrer"
class={`text-gray-600 dark:text-gray-400 hover:underline font-medium transition-colors ${sizeClasses.links}`}
>
{i18n(I18nKey.projectsSource)}
</a>
)}
</div>
<!-- 特色项目标识 -->
{project.featured && (
<div class="absolute top-3 left-3">
<span class="px-2 py-1 text-xs bg-yellow-600/20 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400 rounded-full flex items-center gap-1">
⭐ {i18n(I18nKey.projectsFeatured)}
</span>
</div>
)}
</div>
</div>
<style>
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,101 @@
---
import { sidebarLayoutConfig } from "../../config";
import Calendar from "./Calendar.astro";
import SiteStats from "./SiteStats.astro";
// 获取右侧栏组件配置
const rightSidebarComponents = sidebarLayoutConfig.components.filter(
(component) => {
// 只显示右侧栏且启用的组件
return component.enable && component.sidebar === "right";
},
);
// 按顺序排序组件
rightSidebarComponents.sort((a, b) => a.order - b.order);
// 分离顶部固定和粘性定位的组件
const topComponents = rightSidebarComponents.filter(
(component) => component.position === "top",
);
const stickyComponents = rightSidebarComponents.filter(
(component) => component.position === "sticky",
);
---
<div class="right-sidebar" id="right-sidebar">
<!-- 顶部固定区域 -->
<div class="sidebar-top">
{topComponents.map((component, _index) => (
<div class={`sidebar-component ${component.class || ''}`}
style={`animation-delay: ${component.animationDelay || 0}ms; ${component.style || ''}`}>
{component.type === "site-stats" && <SiteStats />}
{component.type === "calendar" && <Calendar />}
</div>
))}
</div>
<!-- 粘性定位区域 -->
<div class="sidebar-sticky">
{stickyComponents.map((component, _index) => (
<div class={`sidebar-component ${component.class || ''}`}
style={`animation-delay: ${component.animationDelay || 0}ms; ${component.style || ''}`}>
{component.type === "site-stats" && <SiteStats />}
{component.type === "calendar" && <Calendar />}
</div>
))}
</div>
</div>
<style>
.right-sidebar {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
max-width: 300px;
height: fit-content;
position: sticky;
top: 80px;
}
.sidebar-top {
display: flex;
flex-direction: column;
gap: 16px;
}
.sidebar-sticky {
display: flex;
flex-direction: column;
gap: 16px;
position: sticky;
top: 0;
}
.sidebar-component {
opacity: 0;
transform: translateY(20px);
animation: fadeInUp 0.5s forwards;
}
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
}
}
/* 响应式设计 */
@media (max-width: 1280px) {
.right-sidebar {
max-width: 260px;
}
}
@media (max-width: 1024px) {
.right-sidebar {
display: none;
}
}
</style>

View File

@@ -0,0 +1,166 @@
---
import type { MarkdownHeading } from "astro";
import { widgetManager } from "../../utils/widget-manager";
import Announcement from "./Announcement.astro";
import Calendar from "./Calendar.astro";
import Categories from "./Categories.astro";
import MusicPlayer from "./MusicPlayer.svelte";
import Profile from "./Profile.astro";
import SiteStats from "./SiteStats.astro";
import Tags from "./Tags.astro";
import TOC from "./TOC.astro";
interface Props {
class?: string;
headings?: MarkdownHeading[];
isBothSidebarMode?: boolean;
}
const { class: className, headings } = Astro.props;
// 获取配置的组件列表
// 在双侧边栏模式下,桌面端只获取左侧组件
// 手机端仍然获取所有组件不指定sidebar参数以保持原有布局
const isBothSidebarMode = Astro.props.isBothSidebarMode ?? false;
const topComponents = isBothSidebarMode
? widgetManager.getComponentsByPosition("top", "left")
: widgetManager.getComponentsByPosition("top");
const stickyComponents = isBothSidebarMode
? widgetManager.getComponentsByPosition("sticky", "left")
: widgetManager.getComponentsByPosition("sticky");
// 组件映射表
const componentMap = {
profile: Profile,
announcement: Announcement,
categories: Categories,
tags: Tags,
toc: TOC,
"music-player": MusicPlayer,
"site-stats": SiteStats,
calendar: Calendar,
};
// 渲染组件的辅助函数
function renderComponent(component: any, index: number, _components: any[]) {
const ComponentToRender =
componentMap[component.type as keyof typeof componentMap];
if (!ComponentToRender) return null;
const componentClass = widgetManager.getComponentClass(component, index);
const componentStyle = widgetManager.getComponentStyle(component, index);
return {
Component: ComponentToRender,
props: {
class: componentClass,
style: componentStyle,
headings: component.type === "toc" ? headings : undefined,
...component.customProps,
},
};
}
---
<div id="sidebar" class:list={[className, "w-full"]}>
<!-- 顶部固定组件区域 -->
{topComponents.length > 0 && (
<div class="flex flex-col w-full gap-4 mb-4">
{topComponents.map((component, index) => {
const renderData = renderComponent(component, index, topComponents);
if (!renderData) return null;
const { Component, props } = renderData;
return <Component {...props} />;
})}
</div>
)}
<!-- 粘性组件区域 -->
{stickyComponents.length > 0 && (
<div id="sidebar-sticky" class="transition-all duration-700 flex flex-col w-full gap-4 top-4 sticky top-4">
{stickyComponents.map((component, index) => {
const renderData = renderComponent(component, index, stickyComponents);
if (!renderData) return null;
const { Component, props } = renderData;
return <Component {...props} />;
})}
</div>
)}
</div>
<!-- 响应式样式和JavaScript -->
<style>
/* 响应式断点样式 */
@media (max-width: 1280px) {
#sidebar {
display: var(--sidebar-mobile-display, block);
}
}
@media (min-width: 769px) and (max-width: 1279px) {
#sidebar {
display: var(--sidebar-tablet-display, block);
}
}
@media (min-width: 1280px) {
#sidebar {
display: var(--sidebar-desktop-display, block);
}
}
</style>
<script is:inline>
import { widgetManager } from "../../utils/widget-manager";
// 响应式布局管理
class SidebarManager {
constructor() {
this.init();
}
init() {
this.updateResponsiveDisplay();
window.addEventListener('resize', () => this.updateResponsiveDisplay());
// 监听SWUP内容替换事件
if (typeof window !== 'undefined' && window.swup) {
window.swup.hooks.on('content:replace', () => {
// 延迟执行以确保DOM已更新
setTimeout(() => {
this.updateResponsiveDisplay();
}, 100);
});
}
}
updateResponsiveDisplay() {
const breakpoints = widgetManager.getBreakpoints();
const width = window.innerWidth;
let deviceType;
if (width < breakpoints.mobile) {
deviceType = 'mobile';
} else if (width < breakpoints.tablet) {
deviceType = 'tablet';
} else {
deviceType = 'desktop';
}
const shouldShow = widgetManager.shouldShowSidebar(deviceType);
const sidebar = document.getElementById('sidebar');
if (sidebar) {
sidebar.style.setProperty(
`--sidebar-${deviceType}-display`,
shouldShow ? 'block' : 'none'
);
}
}
}
// 初始化侧边栏管理器
new SidebarManager();
</script>

View File

@@ -0,0 +1,163 @@
---
import { Icon } from "astro-icon/components";
import { siteConfig } from "../../config";
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
import {
getCategoryList,
getSortedPosts,
getTagList,
} from "../../utils/content-utils";
import WidgetLayout from "./WidgetLayout.astro";
interface Props {
class?: string;
style?: string;
}
const { class: className, style } = Astro.props;
// 从配置中获取站点开始日期
const siteStartDate = siteConfig.siteStartDate || "2025-01-01";
// 获取所有文章
const posts = await getSortedPosts();
const categories = await getCategoryList();
const tags = await getTagList();
// 计算总字数
let totalWords = 0;
for (const post of posts) {
if (post.body) {
// 移除 Markdown 空白字符后计算字数
const text = post.body
.replace(/\s+/g, " ") // 合并空白
.trim();
// 分别计算中文字符和英文单词
const chineseChars = text.match(/[\u4e00-\u9fa5]/g) || [];
const englishWords = text.match(/[a-zA-Z]+/g) || [];
totalWords += chineseChars.length + englishWords.length;
}
}
// 格式化数字(添加千位分隔符)
function formatNumber(num: number): string {
return num.toLocaleString();
}
// 获取最新文章日期(忽略置顶状态,只按发布日期排序)
const latestPost = posts.reduce((latest, post) => {
if (!latest) return post;
return post.data.published > latest.data.published ? post : latest;
}, posts[0]);
const lastPostDate = latestPost
? latestPost.data.published.toISOString()
: null;
const stats = [
{
icon: "material-symbols:article-outline",
label: i18n(I18nKey.siteStatsPostCount),
value: posts.length,
},
{
icon: "material-symbols:folder-outline",
label: i18n(I18nKey.siteStatsCategoryCount),
value: categories.length,
},
{
icon: "material-symbols:label-outline",
label: i18n(I18nKey.siteStatsTagCount),
value: tags.length,
},
{
icon: "material-symbols:text-ad-outline-rounded",
label: i18n(I18nKey.siteStatsTotalWords),
value: totalWords,
formatted: true,
},
{
icon: "material-symbols:calendar-clock-outline",
label: i18n(I18nKey.siteStatsRunningDays),
value: 0, // 将由客户端更新
suffix: i18n(I18nKey.siteStatsDays).replace("{days}", ""),
dynamic: true,
id: "running-days",
},
{
icon: "material-symbols:ecg-heart-outline",
label: i18n(I18nKey.siteStatsLastUpdate),
value: 0, // 将由客户端更新
suffix: i18n(I18nKey.siteStatsDaysAgo).replace("{days}", ""),
dynamic: true,
id: "last-update",
},
];
---
<WidgetLayout name={i18n(I18nKey.siteStats)} id="site-stats" class={className} style={style}>
<div class="flex flex-col gap-2">
{stats.map((stat) => (
<div class="flex items-center justify-between px-3 py-1.5">
<div class="flex items-center gap-2.5">
<div class="text-[var(--primary)] text-xl">
<Icon name={stat.icon} />
</div>
<span class="text-neutral-700 dark:text-neutral-300 font-medium text-sm">
{stat.label}
</span>
</div>
<div class="flex items-center">
<span
class="text-base font-bold text-neutral-900 dark:text-neutral-100"
data-stat-id={stat.id}
>
{stat.formatted ? formatNumber(stat.value) : stat.value}
</span>
{stat.suffix && (
<span class="text-sm text-neutral-500 dark:text-neutral-400 ml-1">
{stat.suffix}
</span>
)}
</div>
</div>
))}
</div>
</WidgetLayout>
<script is:inline define:vars={{ siteStartDate, lastPostDate }}>
function updateDynamicStats() {
const today = new Date();
// 更新运行天数
const startDate = new Date(siteStartDate);
const diffTime = Math.abs(today.getTime() - startDate.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
const runningDaysElement = document.querySelector('[data-stat-id="running-days"]');
if (runningDaysElement) {
runningDaysElement.textContent = diffDays.toString();
}
// 更新最后活动时间
if (lastPostDate) {
const lastPost = new Date(lastPostDate);
const timeSinceLastPost = Math.abs(today.getTime() - lastPost.getTime());
const daysSinceLastUpdate = Math.floor(timeSinceLastPost / (1000 * 60 * 60 * 24));
const lastUpdateElement = document.querySelector('[data-stat-id="last-update"]');
if (lastUpdateElement) {
lastUpdateElement.textContent = daysSinceLastUpdate.toString();
}
}
}
// 页面加载时更新
updateDynamicStats();
// 每小时更新一次(可选,因为天数变化较慢)
setInterval(updateDynamicStats, 60 * 60 * 1000);
</script>

View File

@@ -0,0 +1,230 @@
---
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
import Icon from "../misc/Icon.astro";
export interface Props {
skill: {
id: string;
name: string;
description: string;
icon?: string;
category: string;
level: "beginner" | "intermediate" | "advanced" | "expert";
experience: string;
relatedProjects?: string[];
certifications?: string[];
color?: string;
};
size?: "small" | "medium" | "large";
showProgress?: boolean;
showIcon?: boolean;
}
const {
skill,
size = "medium",
showProgress = true,
showIcon = true,
} = Astro.props;
// 技能等级颜色映射
const getLevelColor = (level: string) => {
switch (level) {
case "expert":
return "bg-red-600/20 text-red-700 dark:bg-red-900/30 dark:text-red-400";
case "advanced":
return "bg-orange-600/20 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400";
case "intermediate":
return "bg-yellow-600/20 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400";
case "beginner":
return "bg-green-600/20 text-green-700 dark:bg-green-900/30 dark:text-green-400";
default:
return "bg-gray-600/20 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400";
}
};
// 技能等级文本映射
const getLevelText = (level: string) => {
switch (level) {
case "expert":
return i18n(I18nKey.skillsExpert);
case "advanced":
return i18n(I18nKey.skillsAdvanced);
case "intermediate":
return i18n(I18nKey.skillsIntermediate);
case "beginner":
return i18n(I18nKey.skillsBeginner);
default:
return level;
}
};
// 技能等级进度条宽度
const getLevelWidth = (level: string) => {
switch (level) {
case "expert":
return "100%";
case "advanced":
return "80%";
case "intermediate":
return "60%";
case "beginner":
return "40%";
default:
return "20%";
}
};
// 尺寸样式映射
const getSizeClasses = (size: string) => {
switch (size) {
case "small":
return {
container: "p-4",
icon: "w-8 h-8",
iconText: "text-lg",
title: "text-base",
description: "text-xs line-clamp-2",
badge: "text-xs",
progress: "h-1.5",
};
case "large":
return {
container: "p-6",
icon: "w-14 h-14",
iconText: "text-3xl",
title: "text-xl",
description: "text-sm line-clamp-3",
badge: "text-sm",
progress: "h-3",
};
default: // medium
return {
container: "p-5",
icon: "w-10 h-10",
iconText: "text-xl",
title: "text-lg",
description: "text-sm line-clamp-2",
badge: "text-xs",
progress: "h-2",
};
}
};
const sizeClasses = getSizeClasses(size);
const skillColor = skill.color || "#3B82F6";
---
<div class="bg-transparent rounded-lg border border-black/10 dark:border-white/10 overflow-hidden hover:shadow-lg transition-all duration-300 group">
<div class={sizeClasses.container}>
<div class="flex items-start gap-4">
<!-- 技能图标 -->
{showIcon && skill.icon && (
<div class={`rounded-lg flex items-center justify-center shrink-0 ${sizeClasses.icon}`} style={`background-color: ${skillColor}20`}>
<Icon
icon={skill.icon}
class={sizeClasses.iconText}
color={skillColor}
fallback={skill.name.charAt(0)}
loading="eager"
/>
</div>
)}
<div class="flex-1 min-w-0">
<!-- 技能名称和等级 -->
<div class="flex items-center justify-between mb-2">
<h3 class={`font-semibold text-black/90 dark:text-white/90 ${sizeClasses.title} ${size === 'small' ? 'truncate' : ''}`}>
{skill.name}
</h3>
<span class={`px-2 py-1 rounded-full shrink-0 ml-2 ${sizeClasses.badge} ${getLevelColor(skill.level)}`}>
{getLevelText(skill.level)}
</span>
</div>
<!-- 分类标签 -->
{skill.category && (
<div class="mb-2">
<span class={`px-2 py-1 bg-blue-600/20 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400 rounded ${sizeClasses.badge}`}>
{skill.category}
</span>
</div>
)}
<!-- 技能描述 -->
<p class={`text-black/60 dark:text-white/60 mb-3 ${sizeClasses.description}`}>
{skill.description}
</p>
<!-- 经验和进度条 -->
{showProgress && (
<div class="mb-3">
<div class="flex justify-between text-sm mb-1">
<span class="text-black/60 dark:text-white/60">{i18n(I18nKey.skillExperience)}</span>
<span class="text-black/80 dark:text-white/80">{skill.experience}</span>
</div>
<div class={`w-full bg-gray-200 dark:bg-gray-700 rounded-full ${sizeClasses.progress}`}>
<div
class={`rounded-full transition-all duration-500 ${sizeClasses.progress}`}
style={`width: ${getLevelWidth(skill.level)}; background-color: ${skillColor}`}
></div>
</div>
</div>
)}
<!-- 认证信息 -->
{skill.certifications && skill.certifications.length > 0 && (
<div class="mb-3">
<div class="flex flex-wrap gap-1">
{skill.certifications.map((cert) => (
<span class={`px-2 py-1 bg-green-600/20 text-green-700 dark:bg-green-900/30 dark:text-green-400 rounded ${sizeClasses.badge}`}>
🏆 {cert}
</span>
))}
</div>
</div>
)}
<!-- 相关项目 -->
{skill.relatedProjects && skill.relatedProjects.length > 0 && (
<div class="text-sm text-black/60 dark:text-white/60">
{i18n(I18nKey.skillsProjects)}: {skill.relatedProjects.length}
</div>
)}
</div>
</div>
</div>
</div>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
<script>
// 监听图标加载完成事件
document.addEventListener('DOMContentLoaded', () => {
const skillCard = document.currentScript?.parentElement;
if (skillCard) {
skillCard.classList.add('skill-card');
// 监听图标准备就绪事件
skillCard.addEventListener('iconify-ready', () => {
// 图标加载完成,可以执行额外的初始化逻辑
skillCard.classList.add('icons-loaded');
});
}
});
</script>

View File

@@ -0,0 +1,177 @@
---
export interface Props {
title: string;
value: string | number;
subtitle?: string;
icon?: string;
color?: string;
gradient?: {
from: string;
to: string;
};
size?: "small" | "medium" | "large";
trend?: {
value: number;
isPositive: boolean;
label?: string;
};
link?: {
url: string;
text: string;
};
}
const {
title,
value,
subtitle,
icon,
color = "#3B82F6",
gradient,
size = "medium",
trend,
link,
} = Astro.props;
// 尺寸样式映射
const getSizeClasses = (size: string) => {
switch (size) {
case "small":
return {
container: "p-3",
value: "text-xl",
title: "text-xs",
subtitle: "text-xs",
icon: "text-lg",
iconContainer: "w-8 h-8",
};
case "large":
return {
container: "p-6",
value: "text-4xl",
title: "text-base",
subtitle: "text-sm",
icon: "text-2xl",
iconContainer: "w-12 h-12",
};
default: // medium
return {
container: "p-4",
value: "text-2xl",
title: "text-sm",
subtitle: "text-xs",
icon: "text-xl",
iconContainer: "w-10 h-10",
};
}
};
const sizeClasses = getSizeClasses(size);
// 生成渐变背景样式
const getBackgroundStyle = () => {
if (gradient) {
return `background: linear-gradient(135deg, ${gradient.from}, ${gradient.to})`;
}
return `background: linear-gradient(135deg, ${color}15, ${color}25)`;
};
// 生成图标容器样式
const getIconStyle = () => {
return `background-color: ${color}20; color: ${color}`;
};
---
<div
class="rounded-lg border border-black/10 dark:border-white/10 hover:shadow-lg transition-all duration-300 group cursor-pointer"
style={getBackgroundStyle()}
>
<div class={sizeClasses.container}>
<!-- 顶部:图标和趋势 -->
<div class="flex items-start justify-between mb-3">
<!-- 图标 -->
{icon && (
<div
class={`rounded-lg flex items-center justify-center ${sizeClasses.iconContainer}`}
style={getIconStyle()}
>
<iconify-icon icon={icon} class={sizeClasses.icon}></iconify-icon>
</div>
)}
<!-- 趋势指示器 -->
{trend && (
<div class={`flex items-center gap-1 ${trend.isPositive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
<iconify-icon
icon={trend.isPositive ? 'material-symbols:trending-up' : 'material-symbols:trending-down'}
class="text-sm"
></iconify-icon>
<span class="text-xs font-medium">
{trend.isPositive ? '+' : ''}{trend.value}%
</span>
</div>
)}
</div>
<!-- 主要数值 -->
<div class={`font-bold mb-1 ${sizeClasses.value}`} style={`color: ${color}`}>
{value}
</div>
<!-- 标题 -->
<div class={`font-medium text-black/70 dark:text-white/70 mb-1 ${sizeClasses.title}`}>
{title}
</div>
<!-- 副标题 -->
{subtitle && (
<div class={`text-black/60 dark:text-white/60 ${sizeClasses.subtitle}`}>
{subtitle}
</div>
)}
<!-- 趋势标签 -->
{trend && trend.label && (
<div class={`text-black/50 dark:text-white/50 mt-1 ${sizeClasses.subtitle}`}>
{trend.label}
</div>
)}
<!-- 链接 -->
{link && (
<div class="mt-3">
<a
href={link.url}
class={`text-blue-600 dark:text-blue-400 hover:underline font-medium transition-colors ${sizeClasses.subtitle}`}
>
{link.text} →
</a>
</div>
)}
</div>
<!-- 悬停效果 -->
<div class="absolute inset-0 bg-white/5 dark:bg-black/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-lg pointer-events-none"></div>
</div>
<script>
// 加载 Iconify 图标库
if (typeof window !== 'undefined' && !window.iconifyLoaded) {
const script = document.createElement('script');
script.src = 'https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js';
document.head.appendChild(script);
window.iconifyLoaded = true;
}
// 监听页面导航
if (typeof window !== 'undefined' && !(window as any).iconifyLoaded) {
// 如果页面加载时没有触发 load 事件(可能是 SPA 导航)
(window as any).iconifyLoaded = true;
}
</script>
<style>
.group {
position: relative;
}
</style>

View File

@@ -0,0 +1,366 @@
---
import type { MarkdownHeading } from "astro";
// import { siteConfig } from "../../config";
// import { url } from "../../utils/url-utils";
interface Props {
class?: string;
headings: MarkdownHeading[];
}
let { headings = [] } = Astro.props;
let minDepth = 10;
for (const heading of headings) {
minDepth = Math.min(minDepth, heading.depth);
}
const className = Astro.props.class;
// const isPostsRoute =
// Astro.url.pathname.includes("/posts/") || headings.length > 0;
// const removeTailingHash = (text: string) => {
// let lastIndexOfHash = text.lastIndexOf("#");
// if (lastIndexOfHash !== text.length - 1) {
// return text;
// }
//
// return text.substring(0, lastIndexOfHash);
// };
// let heading1Count = 1;
// const maxLevel = siteConfig.toc.depth;
---
<table-of-contents class:list={[className, "group"]} id="toc">
<!-- TOC内容将由JavaScript动态生成 -->
</table-of-contents>
<script>
class TableOfContents extends HTMLElement {
tocEl: HTMLElement | null = null;
visibleClass = "visible";
observer: IntersectionObserver;
anchorNavTarget: HTMLElement | null = null;
headingIdxMap = new Map<string, number>();
headings: HTMLElement[] = [];
sections: HTMLElement[] = [];
tocEntries: HTMLAnchorElement[] = [];
active: boolean[] = [];
activeIndicator: HTMLElement | null = null;
constructor() {
super();
this.observer = new IntersectionObserver(
this.markVisibleSection, { threshold: 0 }
);
};
markVisibleSection = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
const id = entry.target.children[0]?.getAttribute("id");
const idx = id ? this.headingIdxMap.get(id) : undefined;
if (idx != undefined)
this.active[idx] = entry.isIntersecting;
if (entry.isIntersecting && this.anchorNavTarget == entry.target.firstChild)
this.anchorNavTarget = null;
});
if (!this.active.includes(true))
this.fallback();
this.update();
};
toggleActiveHeading = () => {
let i = this.active.length - 1;
let min = this.active.length - 1, max = -1;
while (i >= 0 && !this.active[i]) {
this.tocEntries[i].classList.remove(this.visibleClass);
i--;
}
while (i >= 0 && this.active[i]) {
this.tocEntries[i].classList.add(this.visibleClass);
min = Math.min(min, i);
max = Math.max(max, i);
i--;
}
while (i >= 0) {
this.tocEntries[i].classList.remove(this.visibleClass);
i--;
}
if (min > max) {
this.activeIndicator?.setAttribute("style", `opacity: 0`);
} else {
let parentOffset = this.tocEl?.getBoundingClientRect().top || 0;
let scrollOffset = this.tocEl?.scrollTop || 0;
let top = this.tocEntries[min].getBoundingClientRect().top - parentOffset + scrollOffset;
let bottom = this.tocEntries[max].getBoundingClientRect().bottom - parentOffset + scrollOffset;
this.activeIndicator?.setAttribute("style", `top: ${top}px; height: ${bottom - top}px`);
}
};
scrollToActiveHeading = () => {
// If the TOC widget can accommodate both the topmost
// and bottommost items, scroll to the topmost item.
// Otherwise, scroll to the bottommost one.
if (this.anchorNavTarget || !this.tocEl) return;
const activeHeading =
document.querySelectorAll<HTMLDivElement>(`#toc .${this.visibleClass}`);
if (!activeHeading.length) return;
const topmost = activeHeading[0];
const bottommost = activeHeading[activeHeading.length - 1];
const tocHeight = this.tocEl.clientHeight;
let top: number;
if (bottommost.getBoundingClientRect().bottom -
topmost.getBoundingClientRect().top < 0.9 * tocHeight)
top = topmost.offsetTop - 32;
else
top = bottommost.offsetTop - tocHeight * 0.8;
this.tocEl.scrollTo({
top,
left: 0,
behavior: "smooth",
});
};
update = () => {
requestAnimationFrame(() => {
this.toggleActiveHeading();
// requestAnimationFrame(() => {
this.scrollToActiveHeading();
// });
});
};
fallback = () => {
if (!this.sections.length) return;
for (let i = 0; i < this.sections.length; i++) {
let offsetTop = this.sections[i].getBoundingClientRect().top;
let offsetBottom = this.sections[i].getBoundingClientRect().bottom;
if (this.isInRange(offsetTop, 0, window.innerHeight)
|| this.isInRange(offsetBottom, 0, window.innerHeight)
|| (offsetTop < 0 && offsetBottom > window.innerHeight)) {
this.markActiveHeading(i);
}
else if (offsetTop > window.innerHeight) break;
}
};
markActiveHeading = (idx: number)=> {
this.active[idx] = true;
};
handleAnchorClick = (event: Event) => {
const anchor = event
.composedPath()
.find((element) => element instanceof HTMLAnchorElement);
if (anchor) {
event.preventDefault(); // 阻止默认的锚点跳转
const id = decodeURIComponent(anchor.hash?.substring(1));
const targetElement = document.getElementById(id);
if (targetElement) {
// 计算目标位置,与移动端保持一致
const navbarHeight = 80; // 导航栏高度
const targetTop = targetElement.getBoundingClientRect().top + window.pageYOffset - navbarHeight;
// 使用与移动端相同的滚动方式
window.scrollTo({
top: targetTop,
behavior: "smooth"
});
}
const idx = this.headingIdxMap.get(id);
if (idx !== undefined) {
this.anchorNavTarget = this.headings[idx];
} else {
this.anchorNavTarget = null;
}
}
};
isInRange(value: number, min: number, max: number) {
return min < value && value < max;
};
connectedCallback() {
// 优先监听动画结束,兜底定时初始化,确保二次刷新也能正常显示 TOC
const element = document.querySelector('.custom-md') || document.querySelector('.prose') || document.querySelector('.markdown-content');
let initialized = false;
const tryInit = () => {
if (!initialized) {
initialized = true;
this.init();
}
};
if (element) {
element.addEventListener('animationend', tryInit, { once: true });
// 兜底无论动画是否触发300ms后强制初始化一次
setTimeout(tryInit, 300);
} else {
// 没有动画元素,直接初始化,兜底再延迟一次
tryInit();
setTimeout(tryInit, 300);
}
};
init() {
// 重新生成TOC内容
this.regenerateTOC();
this.tocEl = this;
this.tocEl.addEventListener("click", this.handleAnchorClick, {
capture: true,
});
this.activeIndicator = document.getElementById("active-indicator");
this.tocEntries = Array.from(
this.querySelectorAll<HTMLAnchorElement>("a[href^='#']")
);
if (this.tocEntries.length === 0) return;
this.sections = new Array(this.tocEntries.length);
this.headings = new Array(this.tocEntries.length);
for (let i = 0; i < this.tocEntries.length; i++) {
const id = decodeURIComponent(this.tocEntries[i].hash?.substring(1));
const heading = document.getElementById(id);
const section = heading?.parentElement;
if (heading instanceof HTMLElement && section instanceof HTMLElement) {
this.headings[i] = heading;
this.sections[i] = section;
this.headingIdxMap.set(id, i);
}
}
this.active = new Array(this.tocEntries.length).fill(false);
this.sections.forEach((section) =>
this.observer.observe(section)
);
this.fallback();
this.update();
};
regenerateTOC(retryCount = 0) {
// 检查是否为文章页面
const isPostPage = window.location.pathname.includes('/posts/') ||
document.querySelector('.custom-md, .markdown-content') !== null;
if (!isPostPage) {
// 如果不是文章页面隐藏TOC
this.innerHTML = '';
return;
}
// 从当前页面重新获取标题
const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'))
.filter(h => h.id)
.map(h => ({
depth: parseInt(h.tagName.substring(1)),
slug: h.id,
text: (h.textContent || '').replace(/#+\s*$/, '')
}));
// 如果没有标题延迟重试最多3次
if (headings.length === 0 && retryCount < 3) {
setTimeout(() => this.regenerateTOC(retryCount + 1), 120);
return;
}
if (headings.length === 0) {
this.innerHTML = '';
return;
}
// 重新生成TOC HTML
const minDepth = Math.min(...headings.map(h => h.depth));
// 获取配置信息
const siteConfig = (window as any).siteConfig || {};
const maxLevel = siteConfig.toc?.depth || 3;
const useJapaneseBadge = siteConfig.toc?.useJapaneseBadge || false;
// 添加调试信息
console.log('TOC Config in regenerateTOC:', { siteConfig: siteConfig.toc, maxLevel, useJapaneseBadge });
// 日语片假名字符集
const japaneseKatakana = [
"ア", "イ", "ウ", "エ", "オ",
"カ", "キ", "ク", "ケ", "コ",
"サ", "シ", "ス", "セ", "ソ",
"タ", "チ", "ツ", "テ", "ト",
"ナ", "ニ", "ヌ", "ネ", "",
"ハ", "ヒ", "フ", "ヘ", "ホ",
"マ", "ミ", "ム", "メ", "モ",
"ヤ", "ユ", "ヨ",
"ラ", "リ", "ル", "レ", "ロ",
"ワ", "ヲ", "ン"
];
let heading1Count = 1;
const tocHTML = headings
.filter(heading => heading.depth < minDepth + maxLevel)
.map(heading => {
const depthClass = heading.depth === minDepth ? '' :
heading.depth === minDepth + 1 ? 'ml-4' : 'ml-8';
let badgeContent = '';
if (heading.depth === minDepth) {
if (useJapaneseBadge && heading1Count - 1 < japaneseKatakana.length) {
badgeContent = japaneseKatakana[heading1Count - 1];
} else {
badgeContent = heading1Count.toString();
}
heading1Count++;
} else if (heading.depth === minDepth + 1) {
badgeContent = '<div class="transition w-2 h-2 rounded-[0.1875rem] bg-[var(--toc-badge-bg)]"></div>';
} else {
badgeContent = '<div class="transition w-1.5 h-1.5 rounded-sm bg-black/5 dark:bg-white/10"></div>';
}
return `<a href="#${heading.slug}" class="px-2 flex gap-2 relative transition w-full min-h-9 rounded-xl hover:bg-[var(--toc-btn-hover)] active:bg-[var(--toc-btn-active)] py-2">
<div class="transition w-5 h-5 shrink-0 rounded-lg text-xs flex items-center justify-center font-bold ${depthClass} ${heading.depth === minDepth ? 'bg-[var(--toc-badge-bg)] text-[var(--btn-content)]' : ''}">
${badgeContent}
</div>
<div class="transition text-sm ${heading.depth <= minDepth + 1 ? 'text-50' : 'text-30'}">${heading.text}</div>
</a>`;
}).join('');
this.innerHTML = tocHTML + '<div id="active-indicator" style="opacity: 0" class="-z-10 absolute bg-[var(--toc-btn-hover)] left-0 right-0 rounded-xl transition-all group-hover:bg-transparent border-2 border-[var(--toc-btn-hover)] group-hover:border-[var(--toc-btn-active)] border-dashed"></div>';
}
disconnectedCallback() {
this.sections.forEach((section) =>
this.observer.unobserve(section)
);
this.observer.disconnect();
this.tocEl?.removeEventListener("click", this.handleAnchorClick);
};
}
if (!customElements.get("table-of-contents")) {
customElements.define("table-of-contents", TableOfContents);
}
</script>
<style>
table-of-contents#toc {
max-height: 100%;
overflow: auto;
position: relative;
scroll-behavior: smooth;
display: block;
}
table-of-contents#toc::-webkit-scrollbar {
display: none;
}
</style>

View File

@@ -0,0 +1,38 @@
---
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
import { getTagList } from "../../utils/content-utils";
import { getTagUrl } from "../../utils/url-utils";
import { widgetManager } from "../../utils/widget-manager";
import ButtonTag from "../control/ButtonTag.astro";
import WidgetLayout from "./WidgetLayout.astro";
const tags = await getTagList();
const COLLAPSED_HEIGHT = "7.5rem";
// 使用统一的组件管理器检查是否应该折叠
const tagsComponent = widgetManager
.getConfig()
.components.find((c) => c.type === "tags");
const isCollapsed = tagsComponent
? widgetManager.isCollapsed(tagsComponent, tags.length)
: false;
interface Props {
class?: string;
style?: string;
}
const className = Astro.props.class;
const style = Astro.props.style;
---
<WidgetLayout name={i18n(I18nKey.tags)} id="tags" isCollapsed={isCollapsed} collapsedHeight={COLLAPSED_HEIGHT} class={className} style={style}>
<div class="flex gap-2 flex-wrap">
{tags.map(t => (
<ButtonTag href={getTagUrl(t.name)} label={`View all posts with the ${t.name.trim()} tag`}>
{t.name.trim()}
</ButtonTag>
))}
</div>
</WidgetLayout>

View File

@@ -0,0 +1,384 @@
---
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
import Icon from "../misc/Icon.astro";
export interface Props {
item: {
id: string;
title: string;
description: string;
type: "education" | "work" | "project" | "achievement";
startDate: string;
endDate?: string;
location?: string;
organization?: string;
position?: string;
skills?: string[];
achievements?: string[];
links?: {
name: string;
url: string;
type: "website" | "certificate" | "project" | "other";
}[];
icon?: string;
color?: string;
featured?: boolean;
};
showTimeline?: boolean;
size?: "small" | "medium" | "large";
layout?: "card" | "timeline";
}
const {
item,
showTimeline = true,
size = "medium",
layout = "timeline",
} = Astro.props;
// 类型图标映射
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";
}
};
// 类型颜色映射
const getTypeColor = (type: string) => {
switch (type) {
case "education":
return "bg-blue-600/20 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400";
case "work":
return "bg-green-600/20 text-green-700 dark:bg-green-900/30 dark:text-green-400";
case "project":
return "bg-purple-600/20 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400";
case "achievement":
return "bg-orange-600/20 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400";
default:
return "bg-gray-600/20 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400";
}
};
// 类型文本映射
const getTypeText = (type: string) => {
switch (type) {
case "education":
return i18n(I18nKey.timelineEducation);
case "work":
return i18n(I18nKey.timelineWork);
case "project":
return i18n(I18nKey.timelineProject);
case "achievement":
return i18n(I18nKey.timelineAchievement);
default:
return type;
}
};
// 链接图标映射
const getLinkIcon = (type: string) => {
switch (type) {
case "certificate":
return "🏆";
case "project":
return "🔗";
case "website":
return "🌐";
default:
return "📄";
}
};
// 格式化日期
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("zh-CN", { year: "numeric", month: "long" });
};
// 计算持续时间
const getDuration = (startDate: string, endDate?: string) => {
const start = new Date(startDate);
const end = endDate ? new Date(endDate) : new Date();
const diffTime = Math.abs(end.getTime() - start.getTime());
const diffMonths = Math.ceil(diffTime / (1000 * 60 * 60 * 24 * 30));
if (diffMonths < 12) {
return `${diffMonths} ${i18n(I18nKey.timelineMonths)}`;
}
const years = Math.floor(diffMonths / 12);
const months = diffMonths % 12;
if (months === 0) {
return `${years} ${i18n(I18nKey.timelineYears)}`;
}
return `${years} ${i18n(I18nKey.timelineYears)} ${months} ${i18n(I18nKey.timelineMonths)}`;
};
// 尺寸样式映射
const getSizeClasses = (size: string) => {
switch (size) {
case "small":
return {
container: "p-4",
node: "w-8 h-8",
nodeIcon: "text-lg",
title: "text-lg",
meta: "text-xs",
description: "text-sm",
badge: "text-xs",
};
case "large":
return {
container: "p-8",
node: "w-16 h-16",
nodeIcon: "text-2xl",
title: "text-2xl",
meta: "text-base",
description: "text-base",
badge: "text-sm",
};
default: // medium
return {
container: "p-6",
node: "w-12 h-12",
nodeIcon: "text-xl",
title: "text-xl",
meta: "text-sm",
description: "text-sm",
badge: "text-xs",
};
}
};
const sizeClasses = getSizeClasses(size);
const itemColor = item.color || "#3B82F6";
---
{layout === 'timeline' ? (
<!-- 时间线布局 -->
<div class="relative flex items-start gap-6">
<!-- 时间线节点 -->
{showTimeline && (
<div class={`relative z-10 rounded-full flex items-center justify-center shrink-0 ${sizeClasses.node}`} style={`background-color: ${itemColor}`}>
<Icon
icon={item.icon || getTypeIcon(item.type)}
class={`text-white ${sizeClasses.nodeIcon}`}
color="white"
fallback={item.title.charAt(0)}
loading="eager"
/>
</div>
)}
<!-- 内容卡片 -->
<div class="flex-1 bg-transparent rounded-lg border border-black/10 dark:border-white/10 hover:shadow-lg transition-shadow duration-300">
<div class={sizeClasses.container}>
<!-- 标题和类型 -->
<div class="flex items-start justify-between mb-3">
<div>
<h3 class={`font-semibold text-black/90 dark:text-white/90 mb-1 ${sizeClasses.title}`}>
{item.title}
{item.featured && (
<span class="ml-2 text-yellow-500">⭐</span>
)}
</h3>
{item.organization && (
<div class={`text-black/70 dark:text-white/70 ${sizeClasses.meta}`}>
{item.organization} {item.position && `• ${item.position}`}
</div>
)}
</div>
<span class={`px-2 py-1 rounded-full shrink-0 ml-4 ${sizeClasses.badge} ${getTypeColor(item.type)}`}>
{getTypeText(item.type)}
</span>
</div>
<!-- 时间和地点信息 -->
<div class={`flex items-center gap-4 mb-3 text-black/60 dark:text-white/60 ${sizeClasses.meta}`}>
<div>
{formatDate(item.startDate)} - {item.endDate ? formatDate(item.endDate) : i18n(I18nKey.timelinePresent)}
</div>
<div>•</div>
<div>{getDuration(item.startDate, item.endDate)}</div>
{item.location && (
<>
<div>•</div>
<div>📍 {item.location}</div>
</>
)}
</div>
<!-- 描述 -->
<p class={`text-black/70 dark:text-white/70 mb-4 ${sizeClasses.description}`}>
{item.description}
</p>
<!-- 成就 -->
{item.achievements && item.achievements.length > 0 && (
<div class="mb-4">
<h4 class={`font-semibold text-black/80 dark:text-white/80 mb-2 ${sizeClasses.meta}`}>
{i18n(I18nKey.timelineAchievements)}
</h4>
<ul class="space-y-1">
{item.achievements.map((achievement) => (
<li class={`text-black/70 dark:text-white/70 flex items-start gap-2 ${sizeClasses.description}`}>
<span class="text-green-500 mt-1">•</span>
<span>{achievement}</span>
</li>
))}
</ul>
</div>
)}
<!-- 技能 -->
{item.skills && item.skills.length > 0 && (
<div class="mb-4">
<div class="flex flex-wrap gap-1">
{item.skills.map((skill) => (
<span class={`px-2 py-1 bg-gray-600/20 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded ${sizeClasses.badge}`}>
{skill}
</span>
))}
</div>
</div>
)}
<!-- 链接 -->
{item.links && item.links.length > 0 && (
<div class="flex gap-4">
{item.links.map((link) => (
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
class={`text-blue-600 dark:text-blue-400 hover:underline font-medium flex items-center gap-1 transition-colors ${sizeClasses.meta}`}
>
{getLinkIcon(link.type)}
{link.name}
</a>
))}
</div>
)}
</div>
</div>
</div>
) : (
<!-- 卡片布局 -->
<div class="bg-transparent rounded-lg border border-black/10 dark:border-white/10 hover:shadow-lg transition-shadow duration-300">
<div class={sizeClasses.container}>
<!-- 图标和标题 -->
<div class="flex items-start gap-4 mb-3">
<div class={`rounded-lg flex items-center justify-center shrink-0 ${sizeClasses.node}`} style={`background-color: ${itemColor}20`}>
<iconify-icon icon={item.icon || getTypeIcon(item.type)} class={sizeClasses.nodeIcon} style={`color: ${itemColor}`}></iconify-icon>
</div>
<div class="flex-1">
<div class="flex items-start justify-between mb-2">
<h3 class={`font-semibold text-black/90 dark:text-white/90 ${sizeClasses.title}`}>
{item.title}
{item.featured && (
<span class="ml-2 text-yellow-500">⭐</span>
)}
</h3>
<span class={`px-2 py-1 rounded-full shrink-0 ml-2 ${sizeClasses.badge} ${getTypeColor(item.type)}`}>
{getTypeText(item.type)}
</span>
</div>
{item.organization && (
<div class={`text-black/70 dark:text-white/70 mb-1 ${sizeClasses.meta}`}>
{item.organization} {item.position && `• ${item.position}`}
</div>
)}
{item.location && (
<div class={`text-black/60 dark:text-white/60 mb-2 ${sizeClasses.meta}`}>
📍 {item.location}
</div>
)}
</div>
</div>
<!-- 时间信息 -->
<div class={`text-black/70 dark:text-white/70 mb-3 ${sizeClasses.meta}`}>
{formatDate(item.startDate)} - {item.endDate ? formatDate(item.endDate) : i18n(I18nKey.timelinePresent)} ({getDuration(item.startDate, item.endDate)})
</div>
<!-- 描述 -->
<p class={`text-black/60 dark:text-white/60 mb-4 ${sizeClasses.description}`}>
{item.description}
</p>
<!-- 成就 -->
{item.achievements && item.achievements.length > 0 && (
<div class="mb-4">
<h4 class={`font-semibold text-black/80 dark:text-white/80 mb-2 ${sizeClasses.meta}`}>
{i18n(I18nKey.timelineAchievements)}
</h4>
<ul class="space-y-1">
{item.achievements.slice(0, 3).map((achievement) => (
<li class={`text-black/70 dark:text-white/70 flex items-start gap-2 ${sizeClasses.description}`}>
<span class="text-green-500 mt-1">•</span>
<span>{achievement}</span>
</li>
))}
{item.achievements.length > 3 && (
<li class={`text-black/60 dark:text-white/60 ${sizeClasses.description}`}>
... 还有 {item.achievements.length - 3} 项成就
</li>
)}
</ul>
</div>
)}
<!-- 技能和链接 -->
<div class="flex items-center justify-between">
{item.skills && item.skills.length > 0 && (
<div class="flex flex-wrap gap-1">
{item.skills.slice(0, 3).map((skill) => (
<span class={`px-2 py-1 bg-gray-600/20 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded ${sizeClasses.badge}`}>
{skill}
</span>
))}
{item.skills.length > 3 && (
<span class={`px-2 py-1 bg-gray-600/20 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400 rounded ${sizeClasses.badge}`}>
+{item.skills.length - 3}
</span>
)}
</div>
)}
{item.links && item.links.length > 0 && (
<div class="flex gap-3">
{item.links.slice(0, 2).map((link) => (
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
class={`text-blue-600 dark:text-blue-400 hover:underline font-medium transition-colors ${sizeClasses.meta}`}
>
{getLinkIcon(link.type)} {link.name}
</a>
))}
</div>
)}
</div>
</div>
</div>
)}
<script>
// 加载 Iconify 图标库
if (typeof window !== 'undefined' && !(window as any).iconifyLoaded) {
const script = document.createElement('script');
script.src = 'https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js';
document.head.appendChild(script);
(window as any).iconifyLoaded = true;
}
</script>

View File

@@ -0,0 +1,63 @@
---
import { Icon } from "astro-icon/components";
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
interface Props {
id: string;
name?: string;
isCollapsed?: boolean;
collapsedHeight?: string;
class?: string;
style?: string;
}
const { id, name, isCollapsed, collapsedHeight, style } = Astro.props;
const className = Astro.props.class;
---
<widget-layout data-id={id} data-is-collapsed={String(isCollapsed)} class={"pb-4 card-base " + className} style={style}>
<div class="font-bold transition text-lg text-neutral-900 dark:text-neutral-100 relative ml-8 mt-4 mb-2 flex items-center
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
before:absolute before:left-[-16px] before:top-[5.5px]">
{name}
<slot name="title-icon" />
</div>
<div id={id} class:list={["collapse-wrapper px-4 overflow-hidden", {"collapsed": isCollapsed}]}>
<slot></slot>
</div>
{isCollapsed && <div class="expand-btn px-4 -mb-2">
<button class="btn-plain rounded-lg w-full h-9">
<div class="text-[var(--primary)] flex items-center justify-center gap-2 -translate-x-2">
<Icon name="material-symbols:more-horiz" class="text-[1.75rem]"></Icon> {i18n(I18nKey.more)}
</div>
</button>
</div>}
</widget-layout>
<style define:vars={{ collapsedHeight }}>
.collapsed {
height: var(--collapsedHeight);
}
</style>
<script>
class WidgetLayout extends HTMLElement {
constructor() {
super();
if (this.dataset.isCollapsed !== "true")
return;
const id = this.dataset.id;
const btn = this.querySelector('.expand-btn');
const wrapper = this.querySelector(`#${id}`)
btn!.addEventListener('click', () => {
wrapper!.classList.remove('collapsed');
btn!.classList.add('hidden');
})
}
}
if (!customElements.get("widget-layout")) {
customElements.define("widget-layout", WidgetLayout);
}
</script>

633
src/config.ts Normal file
View File

@@ -0,0 +1,633 @@
import type {
AnnouncementConfig,
CommentConfig,
ExpressiveCodeConfig,
FooterConfig,
FullscreenWallpaperConfig,
LicenseConfig,
MusicPlayerConfig,
NavBarConfig,
ProfileConfig,
SakuraConfig,
SidebarLayoutConfig,
SiteConfig,
} from "./types/config";
import { LinkPreset } from "./types/config";
// 移除i18n导入以避免循环依赖
// 定义站点语言
const SITE_LANG = "zh_CN"; // 语言代码,例如:'en', 'zh_CN', 'ja' 等。
const SITE_TIMEZONE = 8; //设置你的网站时区 from -12 to 12 default in UTC+8
export const siteConfig: SiteConfig = {
title: "海の小屋",
subtitle: "工于至诚,学以致用",
siteURL: "https://blog.namyki.com/", // 请替换为你的站点URL以斜杠结尾
siteStartDate: "2025-12-01", // 站点开始运行日期,用于站点统计组件计算运行天数
timeZone: SITE_TIMEZONE,
lang: SITE_LANG,
themeColor: {
hue: 230, // 主题色的默认色相,范围从 0 到 360。例如红色0青色200蓝绿色250粉色345
fixed: false, // 对访问者隐藏主题色选择器
},
// 特色页面开关配置(关闭不在使用的页面有助于提升SEO,关闭后直接在顶部导航删除对应的页面就行)
featurePages: {
anime: true, // 番剧页面开关
diary: true, // 日记页面开关
friends: true, // 友链页面开关
projects: true, // 项目页面开关
skills: true, // 技能页面开关
timeline: true, // 时间线页面开关
albums: true, // 相册页面开关
devices: true, // 设备页面开关
},
// 顶栏标题配置
navbarTitle: {
// 顶栏标题文本
text: "Namyki",
// 顶栏标题图标路径,默认使用 public/assets/home/home.png
icon: "assets/home/site.png",
},
bangumi: {
userId: "1185095", // 在此处设置你的Bangumi用户ID可以设置为 "sai" 测试
},
anime: {
mode: "bangumi", // 番剧页面模式:"bangumi" 使用Bangumi API"local" 使用本地配置
},
// 文章列表布局配置
postListLayout: {
// 默认布局模式:"list" 列表模式(单列布局),"grid" 网格模式(双列布局)
// 注意:如果侧边栏配置启用了"both"双侧边栏,则无法使用文章列表"grid"网格(双列)布局
defaultMode: "list",
// 是否允许用户切换布局
allowSwitch: true,
},
// 标签样式配置
tagStyle: {
// 是否使用新样式(悬停高亮样式)还是旧样式(外框常亮样式)
useNewStyle: true,
},
// 壁纸模式配置
wallpaperMode: {
// 默认壁纸模式banner=顶部横幅fullscreen=全屏壁纸none=无壁纸
defaultMode: "banner",
// 整体布局方案切换按钮显示设置(默认:"desktop"
// "off" = 不显示
// "mobile" = 仅在移动端显示
// "desktop" = 仅在桌面端显示
// "both" = 在所有设备上显示
showModeSwitchOnMobile: "desktop",
},
banner: {
// 支持单张图片或图片数组,当数组长度 > 1 时自动启用轮播
src: {
desktop: [
"/assets/desktop-banner/wall1.jpg",
"/assets/desktop-banner/wall2.png",
"/assets/desktop-banner/wall3.png",
"/assets/desktop-banner/wall4.png",
// "/assets/desktop-banner/5.webp",
// "/assets/desktop-banner/6.webp",
], // 桌面横幅图片
mobile: [
"/assets/desktop-banner/wall1.jpg",
"/assets/desktop-banner/wall2.png",
"/assets/desktop-banner/wall4.png",
"/assets/desktop-banner/wall4.png",
], // 移动横幅图片
}, // 使用本地横幅图片
position: "center", // 等同于 object-position仅支持 'top', 'center', 'bottom'。默认为 'center'
carousel: {
enable: true, // 为 true 时:为多张图片启用轮播。为 false 时:从数组中随机显示一张图片
interval: 3, // 轮播间隔时间(秒)
},
waves: {
enable: true, // 是否启用水波纹效果(这个功能比较吃性能)
performanceMode: false, // 性能模式:减少动画复杂度(性能提升40%)
mobileDisable: false, // 移动端禁用
},
// PicFlow API支持(智能图片API)
imageApi: {
enable: false, // 启用图片API
url: "http://domain.com/api_v2.php?format=text&count=4", // API地址返回每行一个图片链接的文本
},
// 这里需要使用PicFlow API的Text返回类型,所以我们需要format=text参数
// 项目地址:https://github.com/matsuzaka-yuki/PicFlow-API
// 请自行搭建API
homeText: {
enable: true, // 在主页显示自定义文本
title: "人間は、誰もが孤独でいる", // 主页横幅主标题
subtitle: [
"紡ぐものは全て嘘、だが嘘を愛でる者は幸福を知る",
"世界は美しい、しかし残酷だ",
"俺は独りで強くなる。だから独りで弱さも抱える",
"君と話すと、なんか毎日がちょっと楽しくなるんだ",
"幸せは他者から与えられたものではなく、自ら創造するもの",
],
typewriter: {
enable: true, // 启用副标题打字机效果
speed: 100, // 打字速度(毫秒)
deleteSpeed: 50, // 删除速度(毫秒)
pauseTime: 2000, // 完全显示后的暂停时间(毫秒)
},
},
credit: {
enable: false, // 显示横幅图片来源文本
text: "Describe", // 要显示的来源文本
url: "", // (可选)原始艺术品或艺术家页面的 URL 链接
},
navbar: {
transparentMode: "semifull", // 导航栏透明模式:"semi" 半透明加圆角,"full" 完全透明,"semifull" 动态透明
},
},
toc: {
enable: true, // 启用目录功能
depth: 3, // 目录深度1-61 表示只显示 h1 标题2 表示显示 h1 和 h2 标题,依此类推
useJapaneseBadge: true, // 使用日语假名标记(あいうえお...)代替数字,开启后会将 1、2、3... 改为 あ、い、う...
},
generateOgImages: true, // 启用生成OpenGraph图片功能,注意开启后要渲染很长时间,不建议本地调试的时候开启
favicon: [
// 留空以使用默认 favicon
{
src: '/favicon/site.png', // 图标文件路径
// theme: 'light', // 可选,指定主题 'light' | 'dark'
// sizes: '32x32', // 可选,图标大小
}
],
// 字体配置
font: {
// 注意:自定义字体需要在 src/styles/main.css 中引入字体文件
// 注意:字体子集优化功能目前仅支持 TTF 格式字体,开启后需要在生产环境才能看到效果,在Dev环境下显示的是浏览器默认字体!
asciiFont: {
// 英文字体 - 优先级最高
// 指定为英文字体则无论字体包含多大范围,都只会保留 ASCII 字符子集
fontFamily: "ZenMaruGothic-Medium",
fontWeight: "400",
localFonts: ["ZenMaruGothic-Medium.ttf"],
enableCompress: true, // 启用字体子集优化,减少字体文件大小
},
cjkFont: {
// 中日韩字体 - 作为回退字体
fontFamily: "XiangcuiDengcusong",
fontWeight: "500",
localFonts: ["XiangcuiDengcusong.ttf"],
enableCompress: true, // 启用字体子集优化,减少字体文件大小
},
},
showLastModified: true, // 控制“上次编辑”卡片显示的开关
};
export const fullscreenWallpaperConfig: FullscreenWallpaperConfig = {
src: {
desktop: [
"/assets/desktop-banner/wall1.jpg",
"/assets/desktop-banner/wall2.png",
"/assets/desktop-banner/wall4.png",
"/assets/desktop-banner/wall4.png",
], // 桌面横幅图片
mobile: [
"/assets/desktop-banner/wall1.jpg",
"/assets/desktop-banner/wall2.png",
"/assets/desktop-banner/wall4.png",
"/assets/desktop-banner/wall4.png",
], // 移动横幅图片
}, // 使用本地横幅图片
position: "center", // 壁纸位置,等同于 object-position
carousel: {
enable: true, // 启用轮播
interval: 5, // 轮播间隔时间(秒)
},
zIndex: -1, // 层级,确保壁纸在背景层
opacity: 0.8, // 壁纸透明度
blur: 2, // 背景模糊程度
};
export const navBarConfig: NavBarConfig = {
links: [
LinkPreset.Home,
LinkPreset.Archive,
// 支持自定义导航栏链接,并且支持多级菜单,3.1版本新加
{
name: "Links",
url: "/links/",
icon: "material-symbols:link",
children: [
{
name: "GitHub",
url: "https://github.com/Namyki/",
external: true,
icon: "fa6-brands:github",
},
{
name: "Bilibili",
url: "https://space.bilibili.com/40749448",
external: true,
icon: "fa6-brands:bilibili",
},
// {
// name: "抖音",
// url: "https://gitee.com/matsuzakayuki/Mizuki",
// external: true,
// icon: "fa6-brands:tiktok",
// },
],
},
{
name: "My",
url: "/content/",
icon: "material-symbols:person",
children: [
{
name: "Anime",
url: "/anime/",
icon: "material-symbols:movie",
},
{
name: "Diary",
url: "/diary/",
icon: "material-symbols:book",
},
{
name: "Gallery",
url: "/albums/",
icon: "material-symbols:photo-library",
},
{
name: "Devices",
url: "devices/",
icon: "material-symbols:devices",
external: false,
},
],
},
{
name: "About",
url: "/content/",
icon: "material-symbols:info",
children: [
{
name: "About",
url: "/about/",
icon: "material-symbols:person",
},
{
name: "Friends",
url: "/friends/",
icon: "material-symbols:group",
},
],
},
{
name: "Others",
url: "#",
icon: "material-symbols:more-horiz",
children: [
{
name: "Projects",
url: "/projects/",
icon: "material-symbols:work",
},
{
name: "Skills",
url: "/skills/",
icon: "material-symbols:psychology",
},
{
name: "Timeline",
url: "/timeline/",
icon: "material-symbols:timeline",
},
],
},
],
};
export const profileConfig: ProfileConfig = {
avatar: "assets/images/my_own_photo.jpg", // 相对于 /src 目录。如果以 '/' 开头,则相对于 /public 目录
name: "生猛海鲜",
bio: "书到用时方恨少",
typewriter: {
enable: true, // 启用个人简介打字机效果
speed: 200, // 打字速度(毫秒)
},
links: [
{
name: "Bilibli",
icon: "fa6-brands:bilibili",
url: "https://space.bilibili.com/40749448",
},
{
name: "GitHub",
url: "https://github.com/Namyki/",
icon: "fa6-brands:github",
},
],
};
export const licenseConfig: LicenseConfig = {
enable: true,
name: "CC BY-NC-SA 4.0",
url: "https://creativecommons.org/licenses/by-nc-sa/4.0/",
};
export const expressiveCodeConfig: ExpressiveCodeConfig = {
// 注意:某些样式(如背景颜色)已被覆盖,请参阅 astro.config.mjs 文件。
// 请选择深色主题,因为此博客主题目前仅支持深色背景
theme: "github-dark",
// 是否在主题切换时隐藏代码块以避免卡顿问题
hideDuringThemeTransition: true,
};
export const commentConfig: CommentConfig = {
enable: true, // 启用评论功能。当设置为 false 时,评论组件将不会显示在文章区域。
twikoo: {
envId: "https://twikoo.namyki.top/",
lang: "zh_CN", // 设置 Twikoo 评论系统语言为英文
},
};
export const announcementConfig: AnnouncementConfig = {
title: "公告栏", // 公告标题
content: "这是我的博客平台!", // 公告内容
closable: true, // 允许用户关闭公告
link: {
enable: true, // 启用链接
text: "了解更多", // 链接文本
url: "/about/", // 链接 URL
external: false, // 内部链接
},
};
export const musicPlayerConfig: MusicPlayerConfig = {
enable: true, // 启用音乐播放器功能
mode: "local", // 音乐播放器模式,可选 "local" 或 "meting"
local: {
source: {
type: "json",
json: {
url: "assets/music/url/song.json",
transform: "default" // 使用预设的转换函数
}
}
}
// meting_api:
// "https://www.bilibili.uno/api?server=:server&type=:type&id=:id&auth=:auth&r=:r", // Meting API 地址
// id: "9157522026", // 歌单ID
// server: "tencent", // 音乐源服务器。有的meting的api源支持更多平台,一般来说,netease=网易云音乐, tencent=QQ音乐, kugou=酷狗音乐, xiami=虾米音乐, baidu=百度音乐
// type: "playlist", // 播单类型
};
export const footerConfig: FooterConfig = {
enable: false, // 是否启用Footer HTML注入功能
customHtml: "", // HTML格式的自定义页脚信息例如备案号等默认留空
// 也可以直接编辑 FooterConfig.html 文件来添加备案号等自定义内容
// 注意:若 customHtml 不为空,则使用 customHtml 中的内容;若 customHtml 留空,则使用 FooterConfig.html 文件中的内容
// FooterConfig.html 可能会在未来的某个版本弃用
};
/**
* 侧边栏布局配置
* 用于控制侧边栏组件的显示、排序、动画和响应式行为
* sidebar: 控制组件在左侧栏和右侧栏,注意移动端是不会显示右侧栏的内容(unilateral模式除外),在设置了right属性的时候请确保你使用双侧(both)布局
*/
export const sidebarLayoutConfig: SidebarLayoutConfig = {
// 侧边栏位置:单侧(unilateral)或双侧(both)
position: "both",
// 侧边栏组件配置列表
components: [
{
// 组件类型:用户资料组件
type: "profile",
// 是否启用该组件
enable: true,
// 组件显示顺序(数字越小越靠前)
order: 1,
// 组件位置:"top" 表示固定在顶部
position: "top",
// 所在侧边栏
sidebar: "left",
// CSS 类名,用于应用样式和动画
class: "onload-animation",
// 动画延迟时间(毫秒),用于错开动画效果
animationDelay: 0,
},
{
// 组件类型:公告组件
type: "announcement",
// 是否启用该组件(现在通过统一配置控制)
enable: true,
// 组件显示顺序
order: 2,
// 组件位置:"top" 表示固定在顶部
position: "top",
// 所在侧边栏
sidebar: "left",
// CSS 类名
class: "onload-animation",
// 动画延迟时间
animationDelay: 50,
},
{
// 组件类型:分类组件
type: "categories",
// 是否启用该组件
enable: true,
// 组件显示顺序
order: 3,
// 组件位置:"sticky" 表示粘性定位,可滚动
position: "sticky",
// 所在侧边栏
sidebar: "left",
// CSS 类名
class: "onload-animation",
// 动画延迟时间
animationDelay: 150,
// 响应式配置
responsive: {
// 折叠阈值当分类数量超过5个时自动折叠
collapseThreshold: 5,
},
},
{
// 组件类型:标签组件
type: "tags",
// 是否启用该组件
enable: true,
// 组件显示顺序
order: 5,
// 组件位置:"sticky" 表示粘性定位
position: "top",
// 所在侧边栏
sidebar: "left",
// CSS 类名
class: "onload-animation",
// 动画延迟时间
animationDelay: 250,
// 响应式配置
responsive: {
// 折叠阈值当标签数量超过20个时自动折叠
collapseThreshold: 20,
},
},
{
// 组件类型:站点统计组件
type: "site-stats",
// 是否启用该组件
enable: true,
// 组件显示顺序
order: 5,
// 组件位置
position: "top",
// 所在侧边栏
sidebar: "right",
// CSS 类名
class: "onload-animation",
// 动画延迟时间
animationDelay: 200,
},
{
// 组件类型:日历组件(移动端不显示)
type: "calendar",
// 是否启用该组件
enable: true,
// 组件显示顺序
order: 6,
// 组件位置
position: "top",
// 所在侧边栏
sidebar: "right",
// CSS 类名
class: "onload-animation",
// 动画延迟时间
animationDelay: 250,
},
],
// 默认动画配置
defaultAnimation: {
// 是否启用默认动画
enable: true,
// 基础延迟时间(毫秒)
baseDelay: 0,
// 递增延迟时间(毫秒),每个组件依次增加的延迟
increment: 50,
},
// 响应式布局配置
responsive: {
// 断点配置(像素值)
breakpoints: {
// 移动端断点屏幕宽度小于768px
mobile: 768,
// 平板端断点屏幕宽度小于1280px
tablet: 1280,
// 桌面端断点屏幕宽度小于1280px
desktop: 1280,
},
// 不同设备的布局模式
//hidden:不显示侧边栏(桌面端) drawer:抽屉模式(移动端不显示) sidebar:显示侧边栏
layout: {
// 移动端:抽屉模式
mobile: "sidebar",
// 平板端:显示侧边栏
tablet: "sidebar",
// 桌面端:显示侧边栏
desktop: "sidebar",
},
},
};
export const sakuraConfig: SakuraConfig = {
enable: false, // 默认关闭樱花特效
sakuraNum: 21, // 樱花数量
limitTimes: -1, // 樱花越界限制次数,-1为无限循环
size: {
min: 0.5, // 樱花最小尺寸倍数
max: 1.1, // 樱花最大尺寸倍数
},
opacity: {
min: 0.3, // 樱花最小不透明度
max: 0.9, // 樱花最大不透明度
},
speed: {
horizontal: {
min: -1.7, // 水平移动速度最小值
max: -1.2, // 水平移动速度最大值
},
vertical: {
min: 1.5, // 垂直移动速度最小值
max: 2.2, // 垂直移动速度最大值
},
rotation: 0.03, // 旋转速度
fadeSpeed: 0.03, // 消失速度,不应大于最小不透明度
},
zIndex: 100, // 层级,确保樱花在合适的层级显示
};
// Pio 看板娘配置
export const pioConfig: import("./types/config").PioConfig = {
enable: true, // 启用看板娘
models: ["/pio/models/pio/model.json"], // 默认模型路径
position: "left", // 默认位置在右侧
width: 280, // 默认宽度
height: 250, // 默认高度
mode: "draggable", // 默认为可拖拽模式
hiddenOnMobile: true, // 默认在移动设备上隐藏
dialog: {
welcome: "欢迎光临生猛海鲜的小站!", // 欢迎词
touch: [
"干嘛呢?",
"别摸了!",
"HENTAI!",
"我徒弟呢",
], // 触摸提示
home: "点我回主页!", // 首页提示
skin: ["Want to see my new outfit?", "The new outfit looks great~"], // 换装提示
close: "下次见啦~", // 关闭提示
link: "https://github.com/Namyki/", // 关于链接
},
};
// 导出所有配置的统一接口
export const widgetConfigs = {
profile: profileConfig,
announcement: announcementConfig,
music: musicPlayerConfig,
layout: sidebarLayoutConfig,
sakura: sakuraConfig,
fullscreenWallpaper: fullscreenWallpaperConfig,
pio: pioConfig, // 添加 pio 配置
} as const;
export const umamiConfig = {
enabled: true, // 是否显示Umami统计
apiKey: "api_P0STBpmE4d8fDiobOXVs1P0BeFe3eVVg", // API密钥优先从环境变量读取否则使用配置文件中的值
baseUrl: "https://api.umami.is", // Umami Cloud API地址
scripts: `
<script defer src="https://cloud.umami.is/script.js" data-website-id="2b6d264a-76b1-41a8-9a8a-32125c67ada3"></script>
`.trim(), // 上面填你要插入的Script,不用再去Layout中插入
} as const;

View File

@@ -0,0 +1,24 @@
export const PAGE_SIZE = 8;
export const LIGHT_MODE = "light",
DARK_MODE = "dark";
export const DEFAULT_THEME = LIGHT_MODE;
// Banner height unit: vh
export const BANNER_HEIGHT = 35;
export const BANNER_HEIGHT_EXTEND = 30;
export const BANNER_HEIGHT_HOME = BANNER_HEIGHT + BANNER_HEIGHT_EXTEND;
// The height the main panel overlaps the banner, unit: rem
export const MAIN_PANEL_OVERLAPS_BANNER_HEIGHT = 3.5;
// Page width: rem
export const PAGE_WIDTH = 90;
// Category constants
export const UNCATEGORIZED = "uncategorized";
// Wallpaper mode constants
export const WALLPAPER_BANNER = "banner";
export const WALLPAPER_FULLSCREEN = "fullscreen";
export const WALLPAPER_NONE = "none";

14
src/constants/icon.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { Favicon } from "@/types/config.ts";
export const defaultFavicons: Favicon[] = [
{
src: "/favicon/favicon.ico",
theme: "light",
sizes: "64x64",
},
{
src: "/favicon/favicon.ico",
theme: "dark",
sizes: "64x64",
},
];

View File

@@ -0,0 +1,56 @@
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import { LinkPreset, type NavBarLink } from "@/types/config";
export const LinkPresets: { [key in LinkPreset]: NavBarLink } = {
[LinkPreset.Home]: {
name: i18n(I18nKey.home),
url: "/",
icon: "material-symbols:home",
},
[LinkPreset.About]: {
name: i18n(I18nKey.about),
url: "/about/",
icon: "material-symbols:person",
},
[LinkPreset.Archive]: {
name: i18n(I18nKey.archive),
url: "/archive/",
icon: "material-symbols:archive",
},
[LinkPreset.Friends]: {
name: i18n(I18nKey.friends),
url: "/friends/",
icon: "material-symbols:group",
},
[LinkPreset.Anime]: {
name: i18n(I18nKey.anime),
url: "/anime/",
icon: "material-symbols:movie",
},
[LinkPreset.Diary]: {
name: i18n(I18nKey.diary),
url: "/diary/",
icon: "material-symbols:book",
},
[LinkPreset.Gallery]: {
name: i18n(I18nKey.gallery),
url: "/gallery/",
icon: "material-symbols:photo-library",
},
[LinkPreset.Projects]: {
name: i18n(I18nKey.projects),
url: "/projects/",
icon: "material-symbols:work",
},
[LinkPreset.Skills]: {
name: i18n(I18nKey.skills),
url: "/skills/",
icon: "material-symbols:psychology",
},
[LinkPreset.Timeline]: {
name: i18n(I18nKey.timeline),
url: "/timeline/",
icon: "material-symbols:timeline",
},
};

43
src/content.config.ts Normal file
View File

@@ -0,0 +1,43 @@
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const postsCollection = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/posts" }),
schema: z.object({
title: z.string(),
published: z.date(),
updated: z.date().optional(),
draft: z.boolean().optional().default(false),
description: z.string().optional().default(""),
image: z.string().optional().default(""),
tags: z.array(z.string()).optional().default([]),
category: z.string().optional().nullable().default(""),
lang: z.string().optional().default(""),
pinned: z.boolean().optional().default(false),
author: z.string().optional().default(""),
sourceLink: z.string().optional().default(""),
licenseName: z.string().optional().default(""),
licenseUrl: z.string().optional().default(""),
/* Page encryption fields */
encrypted: z.boolean().optional().default(false),
password: z.string().optional().default(""),
/* Custom permalink */
permalink: z.string().optional(),
/* For internal use */
prevTitle: z.string().default(""),
prevSlug: z.string().default(""),
nextTitle: z.string().default(""),
nextSlug: z.string().default(""),
}),
});
const specCollection = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/spec" }),
schema: z.object({}),
});
export const collections = {
posts: postsCollection,
spec: specCollection,
};

37
src/content/config.ts Normal file
View File

@@ -0,0 +1,37 @@
import { defineCollection, z } from "astro:content";
const postsCollection = defineCollection({
schema: z.object({
title: z.string(),
published: z.date(),
updated: z.date().optional(),
draft: z.boolean().optional().default(false),
description: z.string().optional().default(""),
image: z.string().optional().default(""),
tags: z.array(z.string()).optional().default([]),
category: z.string().optional().nullable().default(""),
lang: z.string().optional().default(""),
pinned: z.boolean().optional().default(false),
author: z.string().optional().default(""),
sourceLink: z.string().optional().default(""),
licenseName: z.string().optional().default(""),
licenseUrl: z.string().optional().default(""),
/* Page encryption fields */
encrypted: z.boolean().optional().default(false),
password: z.string().optional().default(""),
/* For internal use */
prevTitle: z.string().default(""),
prevSlug: z.string().default(""),
nextTitle: z.string().default(""),
nextSlug: z.string().default(""),
}),
});
const specCollection = defineCollection({
schema: z.object({}),
});
export const collections = {
posts: postsCollection,
spec: specCollection,
};

View File

@@ -0,0 +1,48 @@
---
title: 《圆圈正义》读后感:永不完美的道德理想与坚持热望
published: 2025-12-07
description: '阅读《圆圈正义》有感而发'
image: './freedom.png'
tags: [BOOK, 书评]
category: '读书笔记'
draft: false
lang: 'zh_CN'
---
>“我们能画出的圆圈总是不够圆,但没有人会因此想取消圆圈。”——《圆圈正义》
***《圆圈正义》***是罗翔老师一部深刻探讨法律、道德与正义的随笔集。他以一位刑法学教授兼公共知识分子的身份,既剖析社会现象,亦直面人心幽暗之处。全书处处闪耀着思辨光芒与真挚的人性关怀,尤其那核心隐喻“圆圈正义”,已成为理解现代性道德困境的重要意象。
# ***不完美的圆圈:理想与现实的永恒张力***
罗翔提出的 ***圆圈正义*** 堪称精妙——正义如同数学中“圆”的概念一样,客观存在却无法在现实中被完美绘制。正如法律永远只能趋近于绝对正义却无法全然达成,人性永远在神圣与幽暗之间挣扎。
这个隐喻警醒我们:*正义有其客观向度(如不杀人、尊重生命是普遍的道德直觉)*,却又在具体执行中充满复杂性与妥协。认识到这种 ***圆而不圆*** 的张力,恰是走出偏狭的道德自恋的第一步。
在阅读过程中我常常自省:我们常常以 **没有人能做到完美** 而放弃追求,又或因理想看似不可能而沮丧怠惰。
罗翔则指出:正因为圆不可画出完美的实体, **努力接近理想状态** 才拥有了道德意义。那些 **“虽不能至,心向往之”** 的坚持,恰是人性最珍贵的光芒。
# ***法律与人性的双重镜鉴***
书中最为深刻之处在于它同时照亮了法律与人性的双重维度:
- **法律的局限与勇气**:罗翔并不迷信法律万能,直言其难免带有权力的烙印与时代限制。然法律的公正实施,是阻止“人祸”的底线屏障。书中对刑法的解说并非仅灌输法条,而重在揭示刑罚背后的价值冲突与人道精神。
- **人性的复杂透视**:他拒绝简单的“性善论”或“性恶论”,而是坦诚直面人性中存在的黑暗冲动(如嫉妒、自私)与崇高可能(如同理心、良心召唤)。我们每个人都可能身处“强人”或“弱者”的位置,道德选择从来与角色无关。
## ***思想共振***
>道德不是简单地追求尽善尽美,而是要求我们尽量避免成为他人苦难的助力。
***——面对无法阻止的恶,至少保持沉默本身就是一种微弱的抵抗。***
>愤怒本身何尝不是一种礼物,它提醒我们内心尚未麻木。
***——关键在于愤怒之后:是滑向仇恨的深渊?还是反思、对话与行动的起点?***
>法律只针对人类有限的行为予以规制,其目的并非制造完人,而是阻止最坏的灾难发生。
***——拒绝将法律置于道德制高点,也拒绝放弃法律作为文明的最后堤坝。***
# ***在局限中仍举灯行走***
在当下社会思潮纷乱、公共讨论常流于偏颇撕裂的语境中,《圆圈正义》如同一盏温暖而清醒的灯火。它在提醒我们:真正的道德生活,不是幻想能一劳永逸画出一个完美的圆来宣告理想已实现,而是日复一日地拿起笔来,在现实的泥泞土地上,带着谦卑、审慎却又无比固执地,画下去。
圆圈难圆,然其理想不陨。正义如星辰,虽不可及,却足以为在黑夜中跋涉者导航。这或许是罗翔老师留给这个喧嚣时代最宝贵的精神馈赠。
:::note[总结]
看完不算舒服,有种被打碎又重组后更结实的感觉。强推给所有对生活、对社会、对自己还有点“困惑”和“不平”的人。
:::

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -0,0 +1,48 @@
---
title: VASP使用教程 [1]
published: 2025-12-01
description: '一个vasp的教程'
image: ''
tags: [VASP, 化学]
category: 'VASP'
draft: false
lang: 'zh_CN'
---
# 一、VASP简介
VASPVienna Ab initio Simulation Package是一个基于密度泛函理论DFT的量子化学计算软件包用于计算材料的电子结构和性质。它广泛应用于材料科学、化学、物理等领域用于研究材料的电子结构、能带结构、光学性质、磁学性质等。
# 二、VASP输入文件
VASP的输入文件主要包括INCAR、POSCAR、POTCAR、KPOINTS等文件。
1. INCAR包含计算参数如交换关联能、电子收敛精度、离子收敛精度等。
2. POSCAR包含晶格结构和原子坐标用于描述材料的晶体结构。
3. POTCAR包含原子势函数用于描述材料的电子结构。
# 三、VASP计算流程
VASP的计算流程主要包括以下几个步骤
1. 准备输入文件根据需设置INCAR、POSCAR、POTCAR、KPOINTS等文件。
2. 运行VASP使用vasp运行计算。
3. 分析结果使用vasp提供的输出文件分析计算结果如能带结构、态密度等。
# 四、VASP计算实例
介绍VASP的计算流程。
# 五、Linux相关命令
:::note[三令五申]
这是我的最后通牒
:::
:::tip[三令五申]
这是我的最后通牒
:::
:::warning[三令五申]
这是我的最后通牒
:::

BIN
src/content/posts/vps/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
src/content/posts/vps/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
src/content/posts/vps/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -0,0 +1,118 @@
---
title: VPS购买与部署
published: 2025-12-02
description: 'VPS从购买到使用'
image: './cover.png'
tags: [VPS, Server]
category: '服务器'
cover: './cover.png'
draft: false
lang: 'zh_CN'
---
# 服务商简介
*CloudCone*在2025年*Cyber Monday*期间上线了一系列美国低价VPS方案其中入门款年付仅为`9.99$`,提供洛杉矶机房*1Gbps*带宽,并提供默认***1个IPv4+3个IPv6地址***。作为成立于2017年的云服务商CloudCone隶属于Edge Centres与Multacom机房同属一家母公司长期在开发者圈内拥有较高关注度。
本次促销覆盖`美国洛杉矶``圣路易斯``雷斯顿`三个数据中心并区分SSD缓存与原生SSD硬盘两类方案让用户能根据存储性能需求自由选择。[CloudCone官网](https://www.cloudcone.com)CloudCone支持 PayPal 与 支付宝 支付
# 促销机器
| 机器名称 | CPU | 内存 | 硬盘 | 流量带宽 | 机房 | 价格 |
| :----: | :----: | :----: | :----: | :----: | :----: | :----:
| CM-25-VPS-1 | 1 core | 1GB | 50GBSSD缓存 | 1TB/月 @1Gbps | 洛杉矶 | $9.99/年 |
| CM-25-SSD-VPS-2 | 3 cores | 2GB | 30GBSSD | 4TB/月 @1Gbps | 洛杉矶、圣路易斯、雷斯顿 | $16.99/年起 |
| CM-25-SSD-VPS-3| 6 cores | 4GB | 60GBSSD | 5TB/月 @1Gbps | 洛杉矶、圣路易斯、雷斯顿 | $28.99/年起 |
:::tip[购买提醒]
默认1个IPv4+3个IPv6
提醒:不同套餐的硬盘类型与数据中心不同,购买前需注意区分。
:::
# 选购指南
## CloudCone促销方案亮点
- 年付低至9.99美元,适合轻量网站、测试环境、个人项目
- 多机房选择:洛杉矶、西海岸,圣路易斯、中心地区,雷斯顿、东海岸
- SSD与SSD缓存两种硬盘系列
- 支持支付宝与PayPal充值
- KVM架构更稳定
>这里选取满足需求的CM-25-VPS-1进行演示
## 购买流程
* 打开[CloudCone官网](https://www.cloudcone.com),点击右上角`Sign Up`按钮
注册新账号(~~身份信息可以不完全准确~~
* 点击`Checkout`,选择`PayPal``支付宝`先充值余额
* 选择`CM-25-VPS-1`,选择洛杉矶机房,点击`Add to Cart`
* 点击`Checkout`,确认订单信息,点击`Deploy`(然后等待部署)
>主页中如图显示即为完成
![插图](./1.png)
# 连接服务器
## 获取服务器信息
* 打开[CloudCone后台](https://www.cloudcone.com),重新设置`root`账户密码
查看服务器IP地址需要注意的是IPV6地址需要在后台手动申请
* 打开任意一款ssh工具这里用Xshell演示
>如下填写
>![插图](./2.png)
>![插图](./3.png)
配置完成点击连接,弹出窗口选保存密钥即可。
## 配置基础环境
* 更新系统软件包
```bash
sudo apt update #这个命令会更新软件包列表,让系统知道有哪些软件包可以更新。
sudo apt upgrade --only-upgrade #这个命令会安装所有可用的软件包更新。
```
* 检查有没有安装VIM主要是我习惯用vim编辑器了Ubuntu是默认自带nano的
```bash
vim --version
```
* 没有显示版本就安装
```bash
sudo apt install vim
```
* 安装wget
```bash
sudo apt install wget
```
到这里服务器的购买和访问就已经告一段落了,接下来就是部署你需要的服务了。
后续的文章,我会部署一些好玩的项目,有缘再见。
# 常见问答Q&A
## CloudCone的9.99美元套餐适合做什么?
适合部署轻量博客、反向代理、小型项目、监控节点等低资源消耗应用。1GB内存+1TB流量对于基础使用已经足够。
## SSD缓存与SSD方案有什么区别
SSD缓存依赖缓存层加速整体读写性能不及原生SSD但容量通常更大、价格更便宜。若对磁盘性能敏感建议选择SSD系列。
## CloudCone是否支持国内用户付款
支持CloudCone提供支付宝充值也支持PayPal国内用户使用无障碍。
## 多机房之间有什么差别?
* 洛杉矶CloudCone主力机房网络覆盖较广
* 圣路易斯:美国中部,访问延迟均衡
* 雷斯顿:美国东部节点,适合面向欧美用户的业务
根据目标用户群选择更合适的数据中心即可。
:::note[网络测试信息]
美国 洛杉矶Los Angeles, CA
测试ip148.135.114.94
测速页https://lg-la.us.cloudc.one/
美国 圣路易斯St. Louis, MO
测试ip66.154.118.2
测速页https://lg-stl.us.cloudc.one/
美国 雷斯顿Reston, VA
测试ip66.154.126.2
测速页https://lg-rstn.us.cloudc.one/

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

51
src/content/spec/about.md Normal file
View File

@@ -0,0 +1,51 @@
:::note[生猛海鲜の小屋导航]
[ 主页 | 档案 📓 | 链接 📖 | 相册 🖼️ | 追番 🎞️ | 项目 🚀 | 知识库 🧠 | 轨迹 ⏳ | 等等 📝 ]
:::
# 📓 博客 (Blog)
* **内容:** 技术笔记 | 行业观察 | 学习心得 | 工具评测 | 生活杂感。
* **目标:** 记录专业思考与系统性总结分享。
* **特点:** 主题驱动 | 逻辑优先 | 技术/非技术并存。
* **实例主题:**
* `[VASP计算手册]`
* `[科研绘图]`
* `[二次元摄影教学]`
# 📖 日记 (Journal / Logs)
* **内容:** 日常片段 | 临时想法 | 读书摘记 | 学习反思 | 心得随笔 | *(可选)加密日志*
* **目标:** 快速捕捉q生活与思考碎片用于个人沉淀。
* **特点:** 时间流驱动 | 内容更具即兴性 | 部分笔记限时可见或私密。
* **更新频率:** `[eg: 日更 / 周更 / 有想法时]`
# 🖼️ 相册 (Albums)
* **内容:** 摄影作品 | 旅行记录 | 生活片段截图 | 可视化项目成果。
* **目标:** 视觉化存档 and 选择性分享。
* **特点:** 主题分类管理 | 按照时间组织 | 附加标题或简述 | 精选筛选。
:::tip[特色界面]
***| 追番 🎞️ | 项目 🚀 | 知识库 🧠 | 轨迹 ⏳ |***
:::
# 🎞️ 正在追番 (Anime/TV Tracker)
* **内容:**
* 当前 **追踪清单** (Title - Season)
* 单集/整体 **观剧状态标记** (👁️🗨️ 在看 / ▶️ 追番中 / ✓ 已完结 / ❌ 弃坑)
* *(可选)* 单集 **快速简评** / 槽点记录
* *(可选)* **深度长评** (可能发布在博客区)
* **目标:** 记录个人娱乐消费进度与偏好, 作为兴趣索引。
# 🚀 项目集 (Projects Portfolio)
* **核心展示项:**
* **暂未公开** 项目 (🔒)
* **目标:** 系统性展示项目经验、技术栈应用与问题解决能力。
# 🧠 知识库 (Knowledge Base / Digital Garden)
* **内容架构:**
* 结构化技术统计
* 命令速查手册 / API备忘
* 精选资源集合
* 阅读笔记 / 书摘精华
* 工作流优化 (工具链)
# ⏳ 人生轨迹 / NOW (Timeline / Now Page)
* **目标:** 提供背景上下文,清晰定义个人发展路径与现状状态。

View File

55
src/data/anime.ts Normal file
View File

@@ -0,0 +1,55 @@
// 本地番剧数据配置
export type AnimeItem = {
title: string;
status: "watching" | "completed" | "planned";
rating: number;
cover: string;
description: string;
episodes: string;
year: string;
genre: string[];
studio: string;
link: string;
progress: number;
totalEpisodes: number;
startDate: string;
endDate: string;
};
const localAnimeList: AnimeItem[] = [
{
title: "Lycoris Recoil",
status: "completed",
rating: 9.8,
cover: "/assets/anime/lkls.webp",
description: "Girl's gunfight",
episodes: "12 episodes",
year: "2022",
genre: ["Action", "Slice of life"],
studio: "A-1 Pictures",
link: "https://www.bilibili.com/bangumi/media/md28338623",
progress: 12,
totalEpisodes: 12,
startDate: "2022-07",
endDate: "2022-09",
},
{
title: "名侦探柯南",
status: "watching",
rating: 9.7,
cover: "/assets/anime/mztkn.webp",
description: "名侦探",
episodes: "12 episodes",
year: "1996",
genre: ["推理", "悬疑"],
studio: "TMS Entertainment",
link: "https://www.bilibili.com/bangumi/media/md28228775",
progress: 1194,
totalEpisodes: 1241,
startDate: "1996-01",
endDate: "Unkown",
},
];
export default localAnimeList;

28
src/data/devices.ts Normal file
View File

@@ -0,0 +1,28 @@
// 设备数据配置文件
export interface Device {
name: string;
image: string;
specs: string;
description: string;
link: string;
}
// 设备类别类型,支持品牌和自定义类别
export type DeviceCategory = {
[categoryName: string]: Device[];
} & {
自定义?: Device[];
};
export const devicesData: DeviceCategory = {
Xiaomi: [
{
name: "Xiaomi 14Pro",
image: "/images/device/mi14p.jpg",
specs: "Black / 12G + 256TB",
description: "Xiaomi 14 Pro超越旗舰超乎所想。",
link: "https://www.mi.com/xiaomi-14-pro",
},
],
};

98
src/data/diary.ts Normal file
View File

@@ -0,0 +1,98 @@
// 日记数据配置
// 用于管理日记页面的数据
export interface DiaryItem {
id: number;
content: string;
date: string;
images?: string[];
location?: string;
mood?: string;
tags?: string[];
}
// 示例日记数据
const diaryData: DiaryItem[] = [
// {
// id: 1,
// content:
// "The falling speed of cherry blossoms is five centimeters per second!",
// date: "2025-01-15T10:30:00Z",
// images: ["/images/diary/sakura.jpg", "/images/diary/1.jpg"],
// },
// {
// id: 2,
// content:
// "The falling speed of cherry blossoms is five centimeters per second!",
// date: "2025-01-15T10:30:00Z",
// images: ["/images/diary/sakura.jpg", "/images/diary/1.jpg"],
// },
];
// 获取日记统计数据
export const getDiaryStats = () => {
const total = diaryData.length;
const hasImages = diaryData.filter(
(item) => item.images && item.images.length > 0,
).length;
const hasLocation = diaryData.filter((item) => item.location).length;
const hasMood = diaryData.filter((item) => item.mood).length;
return {
total,
hasImages,
hasLocation,
hasMood,
imagePercentage: Math.round((hasImages / total) * 100),
locationPercentage: Math.round((hasLocation / total) * 100),
moodPercentage: Math.round((hasMood / total) * 100),
};
};
// 获取日记列表(按时间倒序)
export const getDiaryList = (limit?: number) => {
const sortedData = diaryData.sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
if (limit && limit > 0) {
return sortedData.slice(0, limit);
}
return sortedData;
};
// 获取最新的日记
export const getLatestDiary = () => {
return getDiaryList(1)[0];
};
// 根据ID获取日记
export const getDiaryById = (id: number) => {
return diaryData.find((item) => item.id === id);
};
// 获取包含图片的日记
export const getDiaryWithImages = () => {
return diaryData.filter((item) => item.images && item.images.length > 0);
};
// 根据标签筛选日记
export const getDiaryByTag = (tag: string) => {
return diaryData
.filter((item) => item.tags?.includes(tag))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
};
// 获取所有标签
export const getAllTags = () => {
const tags = new Set<string>();
diaryData.forEach((item) => {
if (item.tags) {
item.tags.forEach((tag) => tags.add(tag));
}
});
return Array.from(tags).sort();
};
export default diaryData;

95
src/data/friends.ts Normal file
View File

@@ -0,0 +1,95 @@
// 友情链接数据配置
// 用于管理友情链接页面的数据
export interface FriendItem {
id: number;
title: string;
imgurl: string;
desc: string;
siteurl: string;
tags: string[];
}
// 友情链接数据
export const friendsData: FriendItem[] = [
{
id: 1,
title: "Astro",
imgurl: "https://avatars.githubusercontent.com/u/44914786?v=4&s=640",
desc: "The web framework for content-driven websites",
siteurl: "https://github.com/withastro/astro",
tags: ["Framework"],
},
// {
// id: 2,
// title: "Mizuki Docs",
// imgurl:
// "http://q.qlogo.cn/headimg_dl?dst_uin=3231515355&spec=640&img_type=jpg",
// desc: "Mizuki User Manual",
// siteurl: "https://docs.mizuki.mysqil.com",
// tags: ["Docs"],
// },
// {
// id: 3,
// title: "Vercel",
// imgurl: "https://avatars.githubusercontent.com/u/14985020?v=4&s=640",
// desc: "Develop. Preview. Ship.",
// siteurl: "https://vercel.com",
// tags: ["Hosting", "Cloud"],
// },
// {
// id: 4,
// title: "Tailwind CSS",
// imgurl: "https://avatars.githubusercontent.com/u/67109815?v=4&s=640",
// desc: "A utility-first CSS framework for rapidly building custom designs",
// siteurl: "https://tailwindcss.com",
// tags: ["CSS", "Framework"],
// },
// {
// id: 5,
// title: "TypeScript",
// imgurl: "https://avatars.githubusercontent.com/u/6154722?v=4&s=640",
// desc: "TypeScript is JavaScript with syntax for types",
// siteurl: "https://www.typescriptlang.org",
// tags: ["Language", "JavaScript"],
// },
// {
// id: 6,
// title: "React",
// imgurl: "https://avatars.githubusercontent.com/u/6412038?v=4&s=640",
// desc: "A JavaScript library for building user interfaces",
// siteurl: "https://reactjs.org",
// tags: ["Framework", "JavaScript"],
// },
// {
// id: 7,
// title: "GitHub",
// imgurl: "https://avatars.githubusercontent.com/u/9919?v=4&s=640",
// desc: "Where the world builds software",
// siteurl: "https://github.com",
// tags: ["Development", "Platform"],
// },
// {
// id: 8,
// title: "MDN Web Docs",
// imgurl: "https://avatars.githubusercontent.com/u/7565578?v=4&s=640",
// desc: "The web's most comprehensive resource for web developers",
// siteurl: "https://developer.mozilla.org",
// tags: ["Docs", "Reference"],
// },
];
// 获取所有友情链接数据
export function getFriendsList(): FriendItem[] {
return friendsData;
}
// 获取随机排序的友情链接数据
export function getShuffledFriendsList(): FriendItem[] {
const shuffled = [...friendsData];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}

82
src/data/projects.ts Normal file
View File

@@ -0,0 +1,82 @@
// Project data configuration file
// Used to manage data for the project display page
export interface Project {
id: string;
title: string;
description: string;
image: string;
category: "web" | "mobile" | "desktop" | "other";
techStack: string[];
status: "completed" | "in-progress" | "planned";
liveDemo?: string;
sourceCode?: string;
startDate: string;
endDate?: string;
featured?: boolean;
tags?: string[];
visitUrl?: string; // 添加前往项目链接字段
}
export const projectsData: Project[] = [
// {
// id: "mizuki-blog",
// title: "Mizuki Blog Theme",
// description:
// "Modern blog theme developed based on the Astro framework, supporting multilingual, dark mode, and responsive design features.",
// image: "",
// category: "web",
// techStack: ["Astro", "TypeScript", "Tailwind CSS", "Svelte"],
// status: "completed",
// liveDemo: "https://blog.example.com",
// sourceCode: "https://github.com/example/mizuki", // 更改为GitHub链接
// visitUrl: "https://blog.example.com", // 添加前往项目链接
// startDate: "2024-01-01",
// endDate: "2024-06-01",
// featured: true,
// tags: ["Blog", "Theme", "Open Source"],
// },
];
// Get project statistics
export const getProjectStats = () => {
const total = projectsData.length;
const completed = projectsData.filter((p) => p.status === "completed").length;
const inProgress = projectsData.filter(
(p) => p.status === "in-progress",
).length;
const planned = projectsData.filter((p) => p.status === "planned").length;
return {
total,
byStatus: {
completed,
inProgress,
planned,
},
};
};
// Get projects by category
export const getProjectsByCategory = (category?: string) => {
if (!category || category === "all") {
return projectsData;
}
return projectsData.filter((p) => p.category === category);
};
// Get featured projects
export const getFeaturedProjects = () => {
return projectsData.filter((p) => p.featured);
};
// Get all tech stacks
export const getAllTechStack = () => {
const techSet = new Set<string>();
projectsData.forEach((project) => {
project.techStack.forEach((tech) => {
techSet.add(tech);
});
});
return Array.from(techSet).sort();
};

310
src/data/skills.ts Normal file
View File

@@ -0,0 +1,310 @@
// Skill data configuration file
// Used to manage data for the skill display page
export interface Skill {
id: string;
name: string;
description: string;
icon: string; // Iconify icon name
category: "frontend" | "backend" | "database" | "tools" | "other";
level: "beginner" | "intermediate" | "advanced" | "expert";
experience: {
years: number;
months: number;
};
projects?: string[]; // Related project IDs
certifications?: string[];
color?: string; // Skill card theme color
}
export const skillsData: Skill[] = [
// Frontend Skills
{
id: "javascript",
name: "JavaScript",
description:
"Modern JavaScript development, including ES6+ syntax, asynchronous programming, and modular development.",
icon: "logos:javascript",
category: "frontend",
level: "advanced",
experience: { years: 0, months: 6 },
projects: ["mizuki-blog", "portfolio-website", "data-visualization-tool"],
color: "#F7DF1E",
},
{
id: "typescript",
name: "TypeScript",
description:
"A type-safe superset of JavaScript that enhances code quality and development efficiency.",
icon: "logos:typescript-icon",
category: "frontend",
level: "advanced",
experience: { years: 0, months: 8 },
projects: ["mizuki-blog", "portfolio-website", "task-manager-app"],
color: "#3178C6",
},
{
id: "react",
name: "React",
description:
"A JavaScript library for building user interfaces, including Hooks, Context, and state management.",
icon: "logos:react",
category: "frontend",
level: "advanced",
experience: { years: 0, months: 10 },
projects: ["portfolio-website", "task-manager-app"],
color: "#61DAFB",
},
{
id: "vue",
name: "Vue.js",
description:
"A progressive JavaScript framework that is easy to learn and use, suitable for rapid development.",
icon: "logos:vue",
category: "frontend",
level: "intermediate",
experience: { years: 0, months: 8 },
projects: ["data-visualization-tool"],
color: "#4FC08D",
},
{
id: "nextjs",
name: "Next.js",
description:
"A production-level React framework supporting SSR, SSG, and full-stack development.",
icon: "logos:nextjs-icon",
category: "frontend",
level: "intermediate",
experience: { years: 0, months: 4 },
projects: ["e-commerce-frontend", "blog-platform"],
color: "#616161", // 更改为深灰色,避免纯黑色
},
{
id: "astro",
name: "Astro",
description:
"A modern static site generator supporting multi-framework integration and excellent performance.",
icon: "logos:astro-icon",
category: "frontend",
level: "advanced",
experience: { years: 0, months: 2 },
projects: ["mizuki-blog"],
color: "#FF5D01",
},
{
id: "vite",
name: "Vite",
description:
"Next-generation frontend build tool with fast cold starts and hot updates.",
icon: "logos:vitejs",
category: "frontend",
level: "intermediate",
experience: { years: 1, months: 2 },
projects: ["vue-project", "react-project"],
color: "#646CFF",
},
// Backend Skills
{
id: "python",
name: "Python",
description:
"A general-purpose programming language suitable for web development, data analysis, machine learning, and more.",
icon: "logos:python",
category: "backend",
level: "intermediate",
experience: { years: 2, months: 10 },
color: "#3776AB",
},
{
id: "cpp",
name: "C++",
description:
"A high-performance systems programming language widely used in game development, system software, and embedded development.",
icon: "logos:c-plusplus",
category: "backend",
level: "intermediate",
experience: { years: 0, months: 4 },
projects: ["game-engine", "system-optimization"],
color: "#00599C",
},
{
id: "c",
name: "C",
description:
"A low-level systems programming language, the foundation for operating systems and embedded systems development.",
icon: "logos:c",
category: "backend",
level: "intermediate",
experience: { years: 0, months: 3 },
projects: ["embedded-system", "kernel-module"],
color: "#A8B9CC",
},
{
id: "django",
name: "Django",
description:
"A high-level Python web framework with rapid development and clean, pragmatic design.",
icon: "logos:django-icon",
category: "backend",
level: "beginner",
experience: { years: 0, months: 6 },
projects: ["blog-backend"],
color: "#092E20",
},
// Database Skills
{
id: "mysql",
name: "MySQL",
description:
"The world's most popular open-source relational database management system, widely used in web applications.",
icon: "logos:mysql-icon",
category: "database",
level: "advanced",
experience: { years: 2, months: 6 },
projects: ["e-commerce-platform", "blog-system"],
color: "#4479A1",
},
// Tools
{
id: "git",
name: "Git",
description:
"A distributed version control system, an essential tool for code management and team collaboration.",
icon: "logos:git-icon",
category: "tools",
level: "advanced",
experience: { years: 3, months: 0 },
color: "#F05032",
},
{
id: "vscode",
name: "VS Code",
description:
"A lightweight but powerful code editor with a rich plugin ecosystem.",
icon: "logos:visual-studio-code",
category: "tools",
level: "expert",
experience: { years: 3, months: 6 },
color: "#007ACC",
},
{
id: "pycharm",
name: "PyCharm",
description:
"A professional Python IDE by JetBrains providing intelligent code analysis and debugging features.",
icon: "logos:pycharm",
category: "tools",
level: "intermediate",
experience: { years: 1, months: 4 },
projects: ["python-web-app", "data-analysis"],
color: "#21D789",
},
{
id: "docker",
name: "Docker",
description:
"A containerization platform that simplifies application deployment and environment management.",
icon: "logos:docker-icon",
category: "tools",
level: "intermediate",
experience: { years: 1, months: 0 },
color: "#2496ED",
},
{
id: "nginx",
name: "Nginx",
description: "A high-performance web server and reverse proxy server.",
icon: "logos:nginx",
category: "tools",
level: "intermediate",
experience: { years: 1, months: 2 },
projects: ["web-server-config", "load-balancer"],
color: "#009639",
},
{
id: "linux",
name: "Linux",
description:
"An open-source operating system, the preferred choice for server deployment and development environments.",
icon: "logos:linux-tux",
category: "tools",
level: "intermediate",
experience: { years: 2, months: 0 },
projects: ["server-management", "shell-scripting"],
color: "#FCC624",
},
{
id: "photoshop",
name: "Photoshop",
description: "Professional image editing and design software.",
icon: "logos:adobe-photoshop",
category: "tools",
level: "intermediate",
experience: { years: 2, months: 6 },
projects: ["ui-design", "image-processing"],
color: "#31A8FF",
},
// Other Skills
{
id: "photography",
name: "photography",
description:
"二次元风光摄影.",
icon: "material-symbols:photo-camera-outline-rounded",
category: "other",
level: "advanced",
experience: { years: 2, months: 4 },
projects: ["modern-api"],
color: "#E10098",
},
];
// Get skill statistics
export const getSkillStats = () => {
const total = skillsData.length;
const byLevel = {
beginner: skillsData.filter((s) => s.level === "beginner").length,
intermediate: skillsData.filter((s) => s.level === "intermediate").length,
advanced: skillsData.filter((s) => s.level === "advanced").length,
expert: skillsData.filter((s) => s.level === "expert").length,
};
const byCategory = {
frontend: skillsData.filter((s) => s.category === "frontend").length,
backend: skillsData.filter((s) => s.category === "backend").length,
database: skillsData.filter((s) => s.category === "database").length,
tools: skillsData.filter((s) => s.category === "tools").length,
other: skillsData.filter((s) => s.category === "other").length,
};
return { total, byLevel, byCategory };
};
// Get skills by category
export const getSkillsByCategory = (category?: string) => {
if (!category || category === "all") {
return skillsData;
}
return skillsData.filter((s) => s.category === category);
};
// Get advanced skills
export const getAdvancedSkills = () => {
return skillsData.filter(
(s) => s.level === "advanced" || s.level === "expert",
);
};
// Calculate total years of experience
export const getTotalExperience = () => {
const totalMonths = skillsData.reduce((total, skill) => {
return total + skill.experience.years * 12 + skill.experience.months;
}, 0);
return {
years: Math.floor(totalMonths / 12),
months: totalMonths % 12,
};
};

110
src/data/timeline.ts Normal file
View File

@@ -0,0 +1,110 @@
// Timeline data configuration file
// Used to manage data for the timeline page
export interface TimelineItem {
id: string;
title: string;
description: string;
type: "education" | "work" | "project" | "achievement";
startDate: string;
endDate?: string; // If empty, it means current
location?: string;
organization?: string;
position?: string;
skills?: string[];
achievements?: string[];
links?: {
name: string;
url: string;
type: "website" | "certificate" | "project" | "other";
}[];
icon?: string; // Iconify icon name
color?: string;
featured?: boolean;
}
export const timelineData: TimelineItem[] = [
{
id: "current-study",
title: "Studying Chemical Engineering",
description:
"Currently studying Chemical Engineering.",
type: "education",
startDate: "2022-09-01",
location: "Dalian",
organization: "Dalian University of Technology",
// skills: ["Java", "Python", "JavaScript", "HTML/CSS", "MySQL"],
// achievements: [
// "Current GPA: 3.6/4.0",
// "Completed data structures and algorithms course project",
// "Participated in multiple course project developments",
// ],
icon: "material-symbols:school",
color: "#059669",
featured: true,
},
];
// Get timeline statistics
export const getTimelineStats = () => {
const total = timelineData.length;
const byType = {
education: timelineData.filter((item) => item.type === "education").length,
work: timelineData.filter((item) => item.type === "work").length,
project: timelineData.filter((item) => item.type === "project").length,
achievement: timelineData.filter((item) => item.type === "achievement")
.length,
};
return { total, byType };
};
// Get timeline items by type
export const getTimelineByType = (type?: string) => {
if (!type || type === "all") {
return timelineData.sort(
(a, b) =>
new Date(b.startDate).getTime() - new Date(a.startDate).getTime(),
);
}
return timelineData
.filter((item) => item.type === type)
.sort(
(a, b) =>
new Date(b.startDate).getTime() - new Date(a.startDate).getTime(),
);
};
// Get featured timeline items
export const getFeaturedTimeline = () => {
return timelineData
.filter((item) => item.featured)
.sort(
(a, b) =>
new Date(b.startDate).getTime() - new Date(a.startDate).getTime(),
);
};
// Get current ongoing items
export const getCurrentItems = () => {
return timelineData.filter((item) => !item.endDate);
};
// Calculate total work experience
export const getTotalWorkExperience = () => {
const workItems = timelineData.filter((item) => item.type === "work");
let totalMonths = 0;
workItems.forEach((item) => {
const startDate = new Date(item.startDate);
const endDate = item.endDate ? new Date(item.endDate) : new Date();
const diffTime = Math.abs(endDate.getTime() - startDate.getTime());
const diffMonths = Math.ceil(diffTime / (1000 * 60 * 60 * 24 * 30));
totalMonths += diffMonths;
});
return {
years: Math.floor(totalMonths / 12),
months: totalMonths % 12,
};
};

11
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="astro/client" />
/// <reference path="../.astro/types.d.ts" />
interface ImportMetaEnv {
readonly UMAMI_API_KEY?: string;
readonly BCRYPT_SALT_ROUNDS?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

59
src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,59 @@
export {};
declare global {
interface HTMLElementTagNameMap {
"table-of-contents": HTMLElement & {
init?: () => void;
};
}
interface Window {
// Define swup type directly since @swup/astro doesn't export AstroIntegration
swup: any;
closeAnnouncement: () => void;
pagefind: {
search: (query: string) => Promise<{
results: Array<{
data: () => Promise<SearchResult>;
}>;
}>;
};
mobileTOCInit?: () => void;
initSemifullScrollDetection?: () => void;
iconifyLoaded?: boolean;
__iconifyLoader?: {
load: () => Promise<void>;
addToPreloadQueue: (icons: string[]) => void;
onLoad: (callback: () => void) => void;
isLoaded: boolean;
};
siteConfig: any;
}
}
interface SearchResult {
url: string;
meta: {
title: string;
};
excerpt: string;
content?: string;
word_count?: number;
filters?: Record<string, unknown>;
anchors?: Array<{
element: string;
id: string;
text: string;
location: number;
}>;
weighted_locations?: Array<{
weight: number;
balanced_score: number;
location: number;
}>;
locations?: number[];
raw_content?: string;
raw_url?: string;
sub_results?: SearchResult[];
}

285
src/i18n/i18nKey.ts Normal file
View File

@@ -0,0 +1,285 @@
enum I18nKey {
home = "home",
about = "about",
archive = "archive",
search = "search",
other = "other",
// 导航栏标题
navLinks = "navLinks",
navMy = "navMy",
navAbout = "navAbout",
navOthers = "navOthers",
tags = "tags",
categories = "categories",
recentPosts = "recentPosts",
postList = "postList",
tableOfContents = "tableOfContents",
// 公告栏
announcement = "announcement",
announcementClose = "announcementClose",
comments = "comments",
untitled = "untitled",
uncategorized = "uncategorized",
noTags = "noTags",
wordCount = "wordCount",
wordsCount = "wordsCount",
minuteCount = "minuteCount",
minutesCount = "minutesCount",
postCount = "postCount",
postsCount = "postsCount",
themeColor = "themeColor",
lightMode = "lightMode",
darkMode = "darkMode",
systemMode = "systemMode",
more = "more",
author = "author",
publishedAt = "publishedAt",
license = "license",
friends = "friends",
friendsSubtitle = "friendsSubtitle",
friendsSearchPlaceholder = "friendsSearchPlaceholder",
friendsFilterAll = "friendsFilterAll",
friendsNoResults = "friendsNoResults",
friendsVisit = "friendsVisit",
friendsCopyLink = "friendsCopyLink",
friendsCopySuccess = "friendsCopySuccess",
friendsTags = "friendsTags",
anime = "anime",
diary = "diary",
gallery = "gallery",
// 番剧页面
animeTitle = "animeTitle",
animeSubtitle = "animeSubtitle",
animeStatusWatching = "animeStatusWatching",
animeStatusCompleted = "animeStatusCompleted",
animeStatusPlanned = "animeStatusPlanned",
animeStatusOnHold = "animeStatusOnHold",
animeStatusDropped = "animeStatusDropped",
animeFilterAll = "animeFilterAll",
animeYear = "animeYear",
animeStudio = "animeStudio",
animeEmpty = "animeEmpty",
animeEmptyBangumi = "animeEmptyBangumi",
animeEmptyLocal = "animeEmptyLocal",
// 短文页面
diarySubtitle = "diarySubtitle",
diaryCount = "diaryCount",
diaryReply = "diaryReply",
diaryTips = "diaryTips",
diaryMinutesAgo = "diaryMinutesAgo",
diaryHoursAgo = "diaryHoursAgo",
diaryDaysAgo = "diaryDaysAgo",
// 404页面
notFound = "notFound",
notFoundTitle = "notFoundTitle",
notFoundDescription = "notFoundDescription",
backToHome = "backToHome",
// 音乐播放器
playlist = "playlist",
// 相册页面
albums = "albums",
albumsSubtitle = "albumsSubtitle",
albumsEmpty = "albumsEmpty",
albumsEmptyDesc = "albumsEmptyDesc",
albumsBackToList = "albumsBackToList",
albumsPhotoCount = "albumsPhotoCount",
albumsPhotosCount = "albumsPhotosCount",
// 设备页面
devices = "devices",
devicesSubtitle = "devicesSubtitle",
// 项目展示页面
projects = "projects",
projectsSubtitle = "projectsSubtitle",
projectsAll = "projectsAll",
projectsWeb = "projectsWeb",
projectsMobile = "projectsMobile",
projectsDesktop = "projectsDesktop",
projectsOther = "projectsOther",
projectTechStack = "projectTechStack",
projectLiveDemo = "projectLiveDemo",
projectSourceCode = "projectSourceCode",
projectDescription = "projectDescription",
projectStatus = "projectStatus",
projectStatusCompleted = "projectStatusCompleted",
projectStatusInProgress = "projectStatusInProgress",
projectStatusPlanned = "projectStatusPlanned",
projectsTotal = "projectsTotal",
projectsCompleted = "projectsCompleted",
projectsInProgress = "projectsInProgress",
projectsTechStack = "projectsTechStack",
projectsFeatured = "projectsFeatured",
projectsPlanned = "projectsPlanned",
projectsDemo = "projectsDemo",
projectsSource = "projectsSource",
projectsVisit = "projectsVisit",
projectsGitHub = "projectsGitHub",
// Skills page
skills = "skills",
skillsSubtitle = "skillsSubtitle",
skillsFrontend = "skillsFrontend",
skillsBackend = "skillsBackend",
skillsDatabase = "skillsDatabase",
skillsTools = "skillsTools",
skillsOther = "skillsOther",
skillLevel = "skillLevel",
skillLevelBeginner = "skillLevelBeginner",
skillLevelIntermediate = "skillLevelIntermediate",
skillLevelAdvanced = "skillLevelAdvanced",
skillLevelExpert = "skillLevelExpert",
skillExperience = "skillExperience",
skillYears = "skillYears",
skillMonths = "skillMonths",
skillsTotal = "skillsTotal",
skillsExpert = "skillsExpert",
skillsAdvanced = "skillsAdvanced",
skillsIntermediate = "skillsIntermediate",
skillsBeginner = "skillsBeginner",
skillsAdvancedTitle = "skillsAdvancedTitle",
skillsProjects = "skillsProjects",
skillsDistribution = "skillsDistribution",
skillsByLevel = "skillsByLevel",
skillsByCategory = "skillsByCategory",
noData = "noData",
// Timeline page
timeline = "timeline",
timelineSubtitle = "timelineSubtitle",
timelineEducation = "timelineEducation",
timelineWork = "timelineWork",
timelineProject = "timelineProject",
timelineAchievement = "timelineAchievement",
timelinePresent = "timelinePresent",
timelineLocation = "timelineLocation",
timelineDescription = "timelineDescription",
timelineMonths = "timelineMonths",
timelineYears = "timelineYears",
timelineTotal = "timelineTotal",
timelineProjects = "timelineProjects",
timelineExperience = "timelineExperience",
timelineCurrent = "timelineCurrent",
timelineHistory = "timelineHistory",
timelineAchievements = "timelineAchievements",
timelineStartDate = "timelineStartDate",
timelineDuration = "timelineDuration",
// 密码保护
passwordProtected = "passwordProtected",
passwordProtectedTitle = "passwordProtectedTitle",
passwordProtectedDescription = "passwordProtectedDescription",
passwordPlaceholder = "passwordPlaceholder",
passwordUnlock = "passwordUnlock",
passwordUnlocking = "passwordUnlocking",
passwordIncorrect = "passwordIncorrect",
passwordDecryptError = "passwordDecryptError",
passwordRequired = "passwordRequired",
passwordVerifying = "passwordVerifying",
passwordDecryptFailed = "passwordDecryptFailed",
passwordDecryptRetry = "passwordDecryptRetry",
passwordUnlockButton = "passwordUnlockButton",
copyFailed = "copyFailed",
syntaxHighlightFailed = "syntaxHighlightFailed",
autoSyntaxHighlightFailed = "autoSyntaxHighlightFailed",
decryptionError = "decryptionError",
//最后编辑时间卡片
lastModifiedPrefix = "lastModifiedPrefix",
lastModifiedOutdated = "lastModifiedOutdated",
year = "year",
month = "month",
day = "day",
hour = "hour",
minute = "minute",
second = "second",
// RSS and Atom
rss = "rss",
rssDescription = "rssDescription",
rssSubtitle = "rssSubtitle",
rssLink = "rssLink",
rssCopyToReader = "rssCopyToReader",
rssCopyLink = "rssCopyLink",
rssLatestPosts = "rssLatestPosts",
rssWhatIsRSS = "rssWhatIsRSS",
rssWhatIsRSSDescription = "rssWhatIsRSSDescription",
rssBenefit1 = "rssBenefit1",
rssBenefit2 = "rssBenefit2",
rssBenefit3 = "rssBenefit3",
rssBenefit4 = "rssBenefit4",
rssHowToUse = "rssHowToUse",
rssCopied = "rssCopied",
rssCopyFailed = "rssCopyFailed",
atom = "atom",
atomDescription = "atomDescription",
atomSubtitle = "atomSubtitle",
atomLink = "atomLink",
atomCopyToReader = "atomCopyToReader",
atomCopyLink = "atomCopyLink",
atomLatestPosts = "atomLatestPosts",
atomWhatIsAtom = "atomWhatIsAtom",
atomWhatIsAtomDescription = "atomWhatIsAtomDescription",
atomBenefit1 = "atomBenefit1",
atomBenefit2 = "atomBenefit2",
atomBenefit3 = "atomBenefit3",
atomBenefit4 = "atomBenefit4",
atomHowToUse = "atomHowToUse",
atomCopied = "atomCopied",
atomCopyFailed = "atomCopyFailed",
// Wallpaper mode
wallpaperBanner = "wallpaperBanner",
wallpaperFullscreen = "wallpaperFullscreen",
wallpaperNone = "wallpaperNone",
// 站点统计
siteStats = "siteStats",
siteStatsPostCount = "siteStatsPostCount",
siteStatsCategoryCount = "siteStatsCategoryCount",
siteStatsTagCount = "siteStatsTagCount",
siteStatsTotalWords = "siteStatsTotalWords",
siteStatsRunningDays = "siteStatsRunningDays",
siteStatsLastUpdate = "siteStatsLastUpdate",
siteStatsDaysAgo = "siteStatsDaysAgo",
siteStatsDays = "siteStatsDays",
// 日历组件
calendarSunday = "calendarSunday",
calendarMonday = "calendarMonday",
calendarTuesday = "calendarTuesday",
calendarWednesday = "calendarWednesday",
calendarThursday = "calendarThursday",
calendarFriday = "calendarFriday",
calendarSaturday = "calendarSaturday",
calendarJanuary = "calendarJanuary",
calendarFebruary = "calendarFebruary",
calendarMarch = "calendarMarch",
calendarApril = "calendarApril",
calendarMay = "calendarMay",
calendarJune = "calendarJune",
calendarJuly = "calendarJuly",
calendarAugust = "calendarAugust",
calendarSeptember = "calendarSeptember",
calendarOctober = "calendarOctober",
calendarNovember = "calendarNovember",
calendarDecember = "calendarDecember",
}
export default I18nKey;

300
src/i18n/languages/en.ts Normal file
View File

@@ -0,0 +1,300 @@
import Key from "../i18nKey";
import type { Translation } from "../translation";
export const en: Translation = {
[Key.home]: "Home",
[Key.about]: "About",
[Key.archive]: "Archive",
[Key.search]: "Search",
[Key.other]: "Other",
// Navigation bar titles
[Key.navLinks]: "Links",
[Key.navMy]: "My",
[Key.navAbout]: "About",
[Key.navOthers]: "Others",
[Key.tags]: "Tags",
[Key.categories]: "Categories",
[Key.recentPosts]: "Recent Posts",
[Key.postList]: "Post List",
[Key.tableOfContents]: "Table of Contents",
// Announcement
[Key.announcement]: "Announcement",
[Key.announcementClose]: "Close",
[Key.comments]: "Comments",
[Key.friends]: "Friends",
[Key.friendsSubtitle]: "Discover more excellent websites",
[Key.friendsSearchPlaceholder]: "Search friend name or description...",
[Key.friendsFilterAll]: "All",
[Key.friendsNoResults]: "No matching friends found",
[Key.friendsVisit]: "Visit",
[Key.friendsCopyLink]: "Copy Link",
[Key.friendsCopySuccess]: "Copied",
[Key.friendsTags]: "Tags",
[Key.untitled]: "Untitled",
[Key.uncategorized]: "Uncategorized",
[Key.noTags]: "No Tags",
[Key.wordCount]: "word",
[Key.wordsCount]: "words",
[Key.minuteCount]: "minute",
[Key.minutesCount]: "minutes",
[Key.postCount]: "post",
[Key.postsCount]: "posts",
[Key.themeColor]: "Theme Color",
[Key.lightMode]: "Light",
[Key.darkMode]: "Dark",
[Key.systemMode]: "System",
[Key.more]: "More",
[Key.author]: "Author",
[Key.publishedAt]: "Published at",
[Key.license]: "License",
[Key.anime]: "Anime",
[Key.diary]: "Diary",
[Key.gallery]: "Gallery",
// Anime Page
[Key.animeTitle]: "My Anime List",
[Key.animeSubtitle]: "Record my anime journey",
[Key.animeStatusWatching]: "Watching",
[Key.animeStatusCompleted]: "Completed",
[Key.animeStatusPlanned]: "Planned",
[Key.animeStatusOnHold]: "On Hold",
[Key.animeStatusDropped]: "Dropped",
[Key.animeFilterAll]: "All",
[Key.animeYear]: "Year",
[Key.animeStudio]: "Studio",
[Key.animeEmpty]: "No anime data",
[Key.animeEmptyBangumi]:
"Please check Bangumi configuration or network connection",
[Key.animeEmptyLocal]:
"Please add anime information in src/data/anime.ts file",
// Diary Page
[Key.diarySubtitle]: "Share life anytime, anywhere",
[Key.diaryCount]: "diary entries",
[Key.diaryReply]: "Reply",
[Key.diaryTips]: "Only show the latest 30 diary entries",
[Key.diaryMinutesAgo]: "minutes ago",
[Key.diaryHoursAgo]: "hours ago",
[Key.diaryDaysAgo]: "days ago",
// 404 Page
[Key.notFound]: "404",
[Key.notFoundTitle]: "Page Not Found",
[Key.notFoundDescription]:
"Sorry, the page you visited does not exist or has been moved.",
[Key.backToHome]: "Back to Home",
// Music Player
[Key.playlist]: "Playlist",
// Albums Page
[Key.albums]: "Albums",
[Key.albumsSubtitle]: "Record beautiful moments in life",
[Key.albumsEmpty]: "No content",
[Key.albumsEmptyDesc]:
"No albums have been created yet. Go add some beautiful memories!",
[Key.albumsBackToList]: "Back to Albums",
// Devices Page
[Key.devices]: "My Devices",
[Key.devicesSubtitle]: "Here are the devices I use in my daily life",
[Key.albumsPhotoCount]: "photo",
[Key.albumsPhotosCount]: "photos",
// Projects Page
[Key.projects]: "Projects",
[Key.projectsSubtitle]: "My development project portfolio",
[Key.projectsAll]: "All",
[Key.projectsWeb]: "Web Applications",
[Key.projectsMobile]: "Mobile Applications",
[Key.projectsDesktop]: "Desktop Applications",
[Key.projectsOther]: "Other",
[Key.projectTechStack]: "Tech Stack",
[Key.projectLiveDemo]: "Live Demo",
[Key.projectSourceCode]: "Source Code",
[Key.projectDescription]: "Project Description",
[Key.projectStatus]: "Status",
[Key.projectStatusCompleted]: "Completed",
[Key.projectStatusInProgress]: "In Progress",
[Key.projectStatusPlanned]: "Planned",
[Key.projectsTotal]: "Total Projects",
[Key.projectsCompleted]: "Completed",
[Key.projectsInProgress]: "In Progress",
[Key.projectsTechStack]: "Tech Stack Statistics",
[Key.projectsFeatured]: "Featured Projects",
[Key.projectsPlanned]: "Planned",
[Key.projectsDemo]: "Live Demo",
[Key.projectsSource]: "Source Code",
[Key.projectsVisit]: "Visit Project",
[Key.projectsGitHub]: "GitHub",
// RSS Page
[Key.rss]: "RSS Feed",
[Key.rssDescription]: "Subscribe to get latest updates",
[Key.rssSubtitle]:
"Subscribe via RSS to get the latest articles and updates imediately",
[Key.rssLink]: "RSS Link",
[Key.rssCopyToReader]: "Copy link to your RSS reader",
[Key.rssCopyLink]: "Copy",
[Key.rssLatestPosts]: "Latest Posts",
[Key.rssWhatIsRSS]: "What is RSS?",
[Key.rssWhatIsRSSDescription]:
"RSS (Really Simple Syndication) is a standard format for publishing frequently updated content. With RSS, you can:",
[Key.rssBenefit1]:
"Get the latest website content in time without manually visiting",
[Key.rssBenefit2]: "Manage subscriptions to multiple websites in one place",
[Key.rssBenefit3]: "Avoid missing important updates and articles",
[Key.rssBenefit4]: "Enjoy an ad-free, clean reading experience",
[Key.rssHowToUse]:
"It is recommended to use Feedly, Inoreader or other RSS readers to subscribe to this site.",
[Key.rssCopied]: "RSS link copied to clipboard!",
[Key.rssCopyFailed]: "Copy failed, please copy the link manually",
// Atom Page
[Key.atom]: "Atom Feed",
[Key.atomDescription]: "Subscribe to get latest updates",
[Key.atomSubtitle]:
"Subscribe via Atom to get the latest articles and updates immediately",
[Key.atomLink]: "Atom Link",
[Key.atomCopyToReader]: "Copy link to your Atom reader",
[Key.atomCopyLink]: "Copy",
[Key.atomLatestPosts]: "Latest Posts",
[Key.atomWhatIsAtom]: "What is Atom?",
[Key.atomWhatIsAtomDescription]:
"Atom (Atom Syndication Format) is an XML-based standard for describing feeds and their items. With Atom, you can:",
[Key.atomBenefit1]:
"Get the latest website content in time without manually visiting",
[Key.atomBenefit2]: "Manage subscriptions to multiple websites in one place",
[Key.atomBenefit3]: "Avoid missing important updates and articles",
[Key.atomBenefit4]: "Enjoy an ad-free, clean reading experience",
[Key.atomHowToUse]:
"It is recommended to use Feedly, Inoreader or other Atom readers to subscribe to this site.",
[Key.atomCopied]: "Atom link copied to clipboard!",
[Key.atomCopyFailed]: "Copy failed, please copy the link manually",
// Wallpaper mode
[Key.wallpaperBanner]: "Banner Mode",
[Key.wallpaperFullscreen]: "Fullscreen Mode",
[Key.wallpaperNone]: "Hide Wallpaper",
// Skills Page
[Key.skills]: "Skills",
[Key.skillsSubtitle]: "My technical skills and expertise",
[Key.skillsFrontend]: "Frontend Development",
[Key.skillsBackend]: "Backend Development",
[Key.skillsDatabase]: "Database",
[Key.skillsTools]: "Development Tools",
[Key.skillsOther]: "Other Skills",
[Key.skillLevel]: "Proficiency",
[Key.skillLevelBeginner]: "Beginner",
[Key.skillLevelIntermediate]: "Intermediate",
[Key.skillLevelAdvanced]: "Advanced",
[Key.skillLevelExpert]: "Expert",
[Key.skillExperience]: "Experience",
[Key.skillYears]: "years",
[Key.skillMonths]: "months",
[Key.skillsTotal]: "Total Skills",
[Key.skillsExpert]: "Expert Level",
[Key.skillsAdvanced]: "Advanced",
[Key.skillsIntermediate]: "Intermediate",
[Key.skillsBeginner]: "Beginner",
[Key.skillsAdvancedTitle]: "Professional Skills",
[Key.skillsProjects]: "Related Projects",
[Key.skillsDistribution]: "Skill Distribution",
[Key.skillsByLevel]: "By Level",
[Key.skillsByCategory]: "By Category",
[Key.noData]: "No data",
// Timeline Page
[Key.timeline]: "Timeline",
[Key.timelineSubtitle]: "My growth journey and important milestones",
[Key.timelineEducation]: "Education",
[Key.timelineWork]: "Work Experience",
[Key.timelineProject]: "Project Experience",
[Key.timelineAchievement]: "Achievements",
[Key.timelinePresent]: "Present",
[Key.timelineLocation]: "Location",
[Key.timelineDescription]: "Detailed Description",
[Key.timelineMonths]: "months",
[Key.timelineYears]: "years",
[Key.timelineTotal]: "Total",
[Key.timelineProjects]: "Projects",
[Key.timelineExperience]: "Work Experience",
[Key.timelineCurrent]: "Current Status",
[Key.timelineHistory]: "History",
[Key.timelineAchievements]: "Achievements",
[Key.timelineStartDate]: "Start Date",
[Key.timelineDuration]: "Duration",
// Password Protection
[Key.passwordProtected]: "Password Protected",
[Key.passwordProtectedTitle]: "This content is password protected",
[Key.passwordProtectedDescription]:
"Please enter the password to view the protected content",
[Key.passwordPlaceholder]: "Enter password",
[Key.passwordUnlock]: "Unlock",
[Key.passwordUnlocking]: "Unlocking...",
[Key.passwordIncorrect]: "Incorrect password, please try again",
[Key.passwordDecryptError]:
"Decryption failed, please check if the password is correct",
[Key.passwordRequired]: "Please enter the password",
[Key.passwordVerifying]: "Verifying...",
[Key.passwordDecryptFailed]: "Decryption failed, please check the password",
[Key.passwordDecryptRetry]: "Decryption failed, please try again",
[Key.passwordUnlockButton]: "Unlock",
[Key.copyFailed]: "Copy failed:",
[Key.syntaxHighlightFailed]: "Syntax highlighting failed:",
[Key.autoSyntaxHighlightFailed]: "Automatic syntax highlighting also failed:",
[Key.decryptionError]: "An error occurred during decryption:",
// Last Modified Time Card
[Key.lastModifiedPrefix]: "Time since last edit: ",
[Key.lastModifiedOutdated]: "Some information may be outdated",
[Key.year]: "y",
[Key.month]: "m",
[Key.day]: "d",
[Key.hour]: "h",
[Key.minute]: "min",
[Key.second]: "s",
// Site Stats
[Key.siteStats]: "Site Statistics",
[Key.siteStatsPostCount]: "Posts",
[Key.siteStatsCategoryCount]: "Categories",
[Key.siteStatsTagCount]: "Tags",
[Key.siteStatsTotalWords]: "Total Words",
[Key.siteStatsRunningDays]: "Running Time",
[Key.siteStatsLastUpdate]: "Last Activity",
[Key.siteStatsDaysAgo]: "{days} days ago",
[Key.siteStatsDays]: "{days} days",
// Calendar Component
[Key.calendarSunday]: "Sun",
[Key.calendarMonday]: "Mon",
[Key.calendarTuesday]: "Tue",
[Key.calendarWednesday]: "Wed",
[Key.calendarThursday]: "Thu",
[Key.calendarFriday]: "Fri",
[Key.calendarSaturday]: "Sat",
[Key.calendarJanuary]: "Jan",
[Key.calendarFebruary]: "Feb",
[Key.calendarMarch]: "Mar",
[Key.calendarApril]: "Apr",
[Key.calendarMay]: "May",
[Key.calendarJune]: "Jun",
[Key.calendarJuly]: "Jul",
[Key.calendarAugust]: "Aug",
[Key.calendarSeptember]: "Sep",
[Key.calendarOctober]: "Oct",
[Key.calendarNovember]: "Nov",
[Key.calendarDecember]: "Dec",
};

303
src/i18n/languages/ja.ts Normal file
View File

@@ -0,0 +1,303 @@
import Key from "../i18nKey";
import type { Translation } from "../translation";
export const ja: Translation = {
[Key.home]: "ホーム",
[Key.about]: "について",
[Key.archive]: "アーカイブ",
[Key.search]: "検索",
[Key.other]: "その他",
// ナビゲーションバータイトル
[Key.navLinks]: "リンク",
[Key.navMy]: "私の",
[Key.navAbout]: "About",
[Key.navOthers]: "その他",
[Key.tags]: "タグ",
[Key.categories]: "カテゴリ",
[Key.recentPosts]: "最近の投稿",
[Key.postList]: "投稿リスト",
[Key.tableOfContents]: "目次",
// お知らせ
[Key.announcement]: "お知らせ",
[Key.announcementClose]: "閉じる",
[Key.comments]: "コメント",
[Key.friends]: "友達",
[Key.friendsSubtitle]: "より優れたウェブサイトを見つける",
[Key.friendsSearchPlaceholder]: "友達の名前または説明を検索...",
[Key.friendsFilterAll]: "すべて",
[Key.friendsNoResults]: "一致する友達が見つかりません",
[Key.friendsVisit]: "訪問",
[Key.friendsCopyLink]: "リンクをコピー",
[Key.friendsCopySuccess]: "コピー済み",
[Key.friendsTags]: "タグ",
[Key.untitled]: "無題",
[Key.uncategorized]: "未分類",
[Key.noTags]: "タグなし",
[Key.wordCount]: "語",
[Key.wordsCount]: "語",
[Key.minuteCount]: "分",
[Key.minutesCount]: "分",
[Key.postCount]: "投稿",
[Key.postsCount]: "投稿",
[Key.themeColor]: "テーマカラー",
[Key.lightMode]: "ライト",
[Key.darkMode]: "ダーク",
[Key.systemMode]: "システム",
[Key.more]: "もっと",
[Key.author]: "著者",
[Key.publishedAt]: "公開日",
[Key.license]: "ライセンス",
[Key.anime]: "アニメ",
[Key.diary]: "日記",
[Key.gallery]: "ギャラリー",
// デバイスページ
[Key.devices]: "私のデバイス",
[Key.devicesSubtitle]: "ここに私の日常で使用するデバイスを表示します",
// アニメページ
[Key.animeTitle]: "私のアニメリスト",
[Key.animeSubtitle]: "私の二次元の旅を記録する",
[Key.animeStatusWatching]: "視聴中",
[Key.animeStatusCompleted]: "完了",
[Key.animeStatusPlanned]: "予定",
[Key.animeStatusOnHold]: "一時停止",
[Key.animeStatusDropped]: "中断",
[Key.animeFilterAll]: "すべて",
[Key.animeYear]: "年",
[Key.animeStudio]: "スタジオ",
[Key.animeEmpty]: "アニメデータなし",
[Key.animeEmptyBangumi]:
"Bangumiの設定またはネットワーク接続を確認してください",
[Key.animeEmptyLocal]:
"src/data/anime.tsファイルにアニメ情報を追加してください",
// 日記ページ
[Key.diarySubtitle]: "いつでもどこでも、生活を共有する",
[Key.diaryCount]: "日記エントリ",
[Key.diaryReply]: "返信",
[Key.diaryTips]: "最新の30件の日記エントリのみを表示",
[Key.diaryMinutesAgo]: "分前",
[Key.diaryHoursAgo]: "時間前",
[Key.diaryDaysAgo]: "日前",
// 404ページ
[Key.notFound]: "404",
[Key.notFoundTitle]: "ページが見つかりません",
[Key.notFoundDescription]:
"申し訳ありませんが、アクセスしたページは存在しないか、移動されています。",
[Key.backToHome]: "ホームに戻る",
// 音楽プレーヤー
[Key.playlist]: "プレイリスト",
// アルバムページ
[Key.albums]: "アルバム",
[Key.albumsSubtitle]: "生活の美しい瞬間を記録する",
[Key.albumsEmpty]: "コンテンツなし",
[Key.albumsEmptyDesc]:
"アルバムがまだ作成されていません。美しい思い出を追加してください!",
[Key.albumsBackToList]: "アルバムに戻る",
[Key.albumsPhotoCount]: "写真",
[Key.albumsPhotosCount]: "写真",
// プロジェクトページ
[Key.projects]: "プロジェクト",
[Key.projectsSubtitle]: "私の開発プロジェクトポートフォリオ",
[Key.projectsAll]: "すべて",
[Key.projectsWeb]: "ウェブアプリケーション",
[Key.projectsMobile]: "モバイルアプリケーション",
[Key.projectsDesktop]: "デスクトップアプリケーション",
[Key.projectsOther]: "その他",
[Key.projectTechStack]: "技術スタック",
[Key.projectLiveDemo]: "ライブデモ",
[Key.projectSourceCode]: "ソースコード",
[Key.projectDescription]: "プロジェクト説明",
[Key.projectStatus]: "ステータス",
[Key.projectStatusCompleted]: "完了",
[Key.projectStatusInProgress]: "進行中",
[Key.projectStatusPlanned]: "予定",
[Key.projectsTotal]: "プロジェクト合計",
[Key.projectsCompleted]: "完了",
[Key.projectsInProgress]: "進行中",
[Key.projectsTechStack]: "技術スタック統計",
[Key.projectsFeatured]: "注目プロジェクト",
[Key.projectsPlanned]: "予定",
[Key.projectsDemo]: "ライブデモ",
[Key.projectsSource]: "ソースコード",
[Key.projectsVisit]: "プロジェクトへ",
[Key.projectsGitHub]: "GitHub",
// [Key.projectsGitee]: "Gitee", // Giteeサポートを削除
// RSSページ
[Key.rss]: "RSSフィード",
[Key.rssDescription]: "最新の更新を購読する",
[Key.rssSubtitle]: "RSSで購読して、最新の記事と更新を第一时间で取得する",
[Key.rssLink]: "RSSリンク",
[Key.rssCopyToReader]: "RSSリンクをリーダーにコピー",
[Key.rssCopyLink]: "リンクをコピー",
[Key.rssLatestPosts]: "最新の投稿",
[Key.rssWhatIsRSS]: "RSSとは",
[Key.rssWhatIsRSSDescription]:
"RSSReally Simple Syndicationは、頻繁に更新されるコンテンツを公開するための標準形式です。RSSを使用すると",
[Key.rssBenefit1]:
"手動で訪問することなく、最新のウェブサイトコンテンツを及时に取得",
[Key.rssBenefit2]: "1か所で複数のウェブサイトの購読を管理",
[Key.rssBenefit3]: "重要な更新や記事を見逃すことを回避",
[Key.rssBenefit4]: "広告なしのクリーンな読書体験を楽しむ",
[Key.rssHowToUse]:
"Feedly、Inoreaderまたは他のRSSリーダーを使用してこのサイトを購読することを推奨します。",
[Key.rssCopied]: "RSSリンクがクリップボードにコピーされました",
[Key.rssCopyFailed]: "コピーに失敗しました。手動でリンクをコピーしてください",
// Atomページ
[Key.atom]: "Atomフィード",
[Key.atomDescription]: "最新の更新を購読する",
[Key.atomSubtitle]: "Atomで購読して、最新の記事と更新を第一时间で取得する",
[Key.atomLink]: "Atomリンク",
[Key.atomCopyToReader]: "Atomリンクをリーダーにコピー",
[Key.atomCopyLink]: "リンクをコピー",
[Key.atomLatestPosts]: "最新の投稿",
[Key.atomWhatIsAtom]: "Atomとは",
[Key.atomWhatIsAtomDescription]:
"Atom連合フォーマットAtom Syndication Formatは、フィードとそのアイテムを記述するためのXMLベースの標準です。Atomを使用すると",
[Key.atomBenefit1]:
"手動で訪問することなく、最新のウェブサイトコンテンツを及时に取得",
[Key.atomBenefit2]: "1か所で複数のウェブサイトの購読を管理",
[Key.atomBenefit3]: "重要な更新や記事を見逃すことを回避",
[Key.atomBenefit4]: "広告なしのクリーンな読書体験を楽しむ",
[Key.atomHowToUse]:
"Feedly、Inoreaderまたは他のAtomリーダーを使用してこのサイトを購読することを推奨します。",
[Key.atomCopied]: "Atomリンクがクリップボードにコピーされました",
[Key.atomCopyFailed]:
"コピーに失敗しました。手動でリンクをコピーしてください",
// スキルページ
[Key.skills]: "スキル",
[Key.skillsSubtitle]: "私の技術スキルと専門知識",
[Key.skillsFrontend]: "フロントエンド開発",
[Key.skillsBackend]: "バックエンド開発",
[Key.skillsDatabase]: "データベース",
[Key.skillsTools]: "開発ツール",
[Key.skillsOther]: "その他のスキル",
[Key.skillLevel]: "熟練度",
[Key.skillLevelBeginner]: "初心者",
[Key.skillLevelIntermediate]: "中級者",
[Key.skillLevelAdvanced]: "上級者",
[Key.skillLevelExpert]: "エキスパート",
[Key.skillExperience]: "経験",
[Key.skillYears]: "年",
[Key.skillMonths]: "ヶ月",
[Key.skillsTotal]: "スキル合計",
[Key.skillsExpert]: "エキスパートレベル",
[Key.skillsAdvanced]: "上級者",
[Key.skillsIntermediate]: "中級者",
[Key.skillsBeginner]: "初心者",
[Key.skillsAdvancedTitle]: "専門スキル",
[Key.skillsProjects]: "関連プロジェクト",
[Key.skillsDistribution]: "スキル分布",
[Key.skillsByLevel]: "レベル別分布",
[Key.skillsByCategory]: "カテゴリ別分布",
// タイムラインページ
[Key.timeline]: "タイムライン",
[Key.timelineSubtitle]: "私の成長の旅と重要なマイルストーン",
[Key.timelineEducation]: "教育",
[Key.timelineWork]: "職歴",
[Key.timelineProject]: "プロジェクト経験",
[Key.timelineAchievement]: "実績",
[Key.timelinePresent]: "現在",
[Key.timelineLocation]: "場所",
[Key.timelineDescription]: "詳細説明",
[Key.timelineMonths]: "ヶ月",
[Key.timelineYears]: "年",
[Key.timelineTotal]: "合計",
[Key.timelineProjects]: "プロジェクト",
[Key.timelineExperience]: "職歴",
[Key.timelineCurrent]: "現在の状態",
[Key.timelineHistory]: "履歴",
[Key.timelineAchievements]: "実績",
[Key.timelineStartDate]: "開始日",
[Key.timelineDuration]: "期間",
// その他
[Key.noData]: "データなし",
// パスワード保護
[Key.passwordProtected]: "パスワード保護",
[Key.passwordProtectedTitle]: "このコンテンツはパスワードで保護されています",
[Key.passwordProtectedDescription]:
"保護されたコンテンツを表示するにはパスワードを入力してください",
[Key.passwordPlaceholder]: "パスワードを入力",
[Key.passwordUnlock]: "ロック解除",
[Key.passwordUnlocking]: "ロック解除中...",
[Key.passwordIncorrect]: "パスワードが間違っています。再試行してください",
[Key.passwordDecryptError]:
"復号化に失敗しました。パスワードが正しいか確認してください",
[Key.passwordRequired]: "パスワードを入力してください",
[Key.passwordVerifying]: "検証中...",
[Key.passwordDecryptFailed]:
"復号化に失敗しました。パスワードを確認してください",
[Key.passwordDecryptRetry]: "復号化に失敗しました。再試行してください",
[Key.passwordUnlockButton]: "ロック解除",
[Key.copyFailed]: "コピーに失敗しました:",
[Key.syntaxHighlightFailed]: "構文ハイライトに失敗しました:",
[Key.autoSyntaxHighlightFailed]: "自動構文ハイライトにも失敗しました:",
[Key.decryptionError]: "復号化中にエラーが発生しました:",
// 最終更新時間カード
[Key.lastModifiedPrefix]: "最終編集からの時間:",
[Key.lastModifiedOutdated]: "一部の情報は古くなっている可能性があります",
[Key.year]: "年",
[Key.month]: "月",
[Key.day]: "日",
[Key.hour]: "時間",
[Key.minute]: "分",
[Key.second]: "秒",
// 壁紙モード
[Key.wallpaperBanner]: "バナーモード",
[Key.wallpaperFullscreen]: "全画面モード",
[Key.wallpaperNone]: "壁紙を非表示",
// サイト統計
[Key.siteStats]: "サイト統計",
[Key.siteStatsPostCount]: "記事数",
[Key.siteStatsCategoryCount]: "カテゴリー数",
[Key.siteStatsTagCount]: "タグ数",
[Key.siteStatsTotalWords]: "総字数",
[Key.siteStatsRunningDays]: "運用日数",
[Key.siteStatsLastUpdate]: "最終更新",
[Key.siteStatsDaysAgo]: "{days}日前",
[Key.siteStatsDays]: "{days}日",
// カレンダーコンポーネント
[Key.calendarSunday]: "日",
[Key.calendarMonday]: "月",
[Key.calendarTuesday]: "火",
[Key.calendarWednesday]: "水",
[Key.calendarThursday]: "木",
[Key.calendarFriday]: "金",
[Key.calendarSaturday]: "土",
[Key.calendarJanuary]: "1月",
[Key.calendarFebruary]: "2月",
[Key.calendarMarch]: "3月",
[Key.calendarApril]: "4月",
[Key.calendarMay]: "5月",
[Key.calendarJune]: "6月",
[Key.calendarJuly]: "7月",
[Key.calendarAugust]: "8月",
[Key.calendarSeptember]: "9月",
[Key.calendarOctober]: "10月",
[Key.calendarNovember]: "11月",
[Key.calendarDecember]: "12月",
};

289
src/i18n/languages/zh_CN.ts Normal file
View File

@@ -0,0 +1,289 @@
import Key from "../i18nKey";
import type { Translation } from "../translation";
export const zh_CN: Translation = {
[Key.home]: "主页",
[Key.about]: "关于我们",
[Key.archive]: "归档",
[Key.search]: "搜索",
[Key.other]: "其他",
// 导航栏标题
[Key.navLinks]: "链接",
[Key.navMy]: "我的",
[Key.navAbout]: "关于",
[Key.navOthers]: "其他",
[Key.tags]: "标签",
[Key.categories]: "分类",
[Key.recentPosts]: "最新文章",
[Key.postList]: "文章列表",
[Key.tableOfContents]: "目录",
// 公告栏
[Key.announcement]: "公告",
[Key.announcementClose]: "关闭",
[Key.comments]: "评论",
[Key.friends]: "友链",
[Key.friendsSubtitle]: "发现更多优秀网站",
[Key.friendsSearchPlaceholder]: "搜索友链名称或描述...",
[Key.friendsFilterAll]: "全部",
[Key.friendsNoResults]: "未找到匹配的友链",
[Key.friendsVisit]: "访问",
[Key.friendsCopyLink]: "复制链接",
[Key.friendsCopySuccess]: "已复制",
[Key.friendsTags]: "标签",
[Key.untitled]: "无标题",
[Key.uncategorized]: "未分类",
[Key.noTags]: "无标签",
[Key.wordCount]: "字",
[Key.wordsCount]: "字",
[Key.minuteCount]: "分钟",
[Key.minutesCount]: "分钟",
[Key.postCount]: "篇文章",
[Key.postsCount]: "篇文章",
[Key.themeColor]: "主题色",
[Key.lightMode]: "亮色",
[Key.darkMode]: "暗色",
[Key.systemMode]: "跟随系统",
[Key.more]: "更多",
[Key.author]: "作者",
[Key.publishedAt]: "发布于",
[Key.license]: "许可协议",
[Key.anime]: "追番",
[Key.diary]: "日记",
[Key.gallery]: "相册",
// 番剧页面
[Key.animeTitle]: "我的追番记录",
[Key.animeSubtitle]: "记录我的二次元之旅",
[Key.animeStatusWatching]: "在看",
[Key.animeStatusCompleted]: "看过",
[Key.animeStatusPlanned]: "想看",
[Key.animeStatusOnHold]: "搁置",
[Key.animeStatusDropped]: "抛弃",
[Key.animeFilterAll]: "全部",
[Key.animeYear]: "年份",
[Key.animeStudio]: "制作",
[Key.animeEmpty]: "暂无追番数据",
[Key.animeEmptyBangumi]: "请检查 Bangumi 配置或网络连接",
[Key.animeEmptyLocal]: "请在 src/data/anime.ts 文件中添加番剧信息",
// 短文页面
[Key.diarySubtitle]: "随时随地,分享生活",
[Key.diaryCount]: "条短文",
[Key.diaryReply]: "回复",
[Key.diaryTips]: "只展示最近30条日记",
[Key.diaryMinutesAgo]: "分钟前",
[Key.diaryHoursAgo]: "小时前",
[Key.diaryDaysAgo]: "天前",
// 404页面
[Key.notFound]: "404",
[Key.notFoundTitle]: "页面未找到",
[Key.notFoundDescription]: "抱歉,您访问的页面不存在或已被移动。",
[Key.backToHome]: "返回首页",
// 音乐播放器
[Key.playlist]: "播放列表",
// 相册页面
[Key.albums]: "相册",
[Key.albumsSubtitle]: "记录生活中的美好瞬间",
[Key.albumsEmpty]: "暂无内容",
[Key.albumsEmptyDesc]: "还没有创建任何相册,快去添加一些美好的回忆吧!",
[Key.albumsBackToList]: "返回相册",
[Key.albumsPhotoCount]: "张照片",
[Key.albumsPhotosCount]: "张照片",
// 设备页面
[Key.devices]: "我的设备",
[Key.devicesSubtitle]: "这里展示了我日常使用的各类设备",
// 项目展示页面
[Key.projects]: "项目展示",
[Key.projectsSubtitle]: "我的开发项目作品集",
[Key.projectsAll]: "全部",
[Key.projectsWeb]: "网页应用",
[Key.projectsMobile]: "移动应用",
[Key.projectsDesktop]: "桌面应用",
[Key.projectsOther]: "其他",
[Key.projectTechStack]: "技术栈",
[Key.projectLiveDemo]: "在线演示",
[Key.projectSourceCode]: "源代码",
[Key.projectDescription]: "项目描述",
[Key.projectStatus]: "状态",
[Key.projectStatusCompleted]: "已完成",
[Key.projectStatusInProgress]: "进行中",
[Key.projectStatusPlanned]: "计划中",
[Key.projectsTotal]: "项目总数",
[Key.projectsCompleted]: "已完成",
[Key.projectsInProgress]: "进行中",
[Key.projectsTechStack]: "技术栈统计",
[Key.projectsFeatured]: "精选项目",
[Key.projectsPlanned]: "计划中",
[Key.projectsDemo]: "在线演示",
[Key.projectsSource]: "源代码",
[Key.projectsVisit]: "前往",
[Key.projectsGitHub]: "GitHub",
// 技能展示页面
[Key.skills]: "技能展示",
[Key.skillsSubtitle]: "我的技术技能和专业知识",
[Key.skillsFrontend]: "前端开发",
[Key.skillsBackend]: "后端开发",
[Key.skillsDatabase]: "数据库",
[Key.skillsTools]: "开发工具",
[Key.skillsOther]: "其他技能",
[Key.skillLevel]: "熟练度",
[Key.skillLevelBeginner]: "初学者",
[Key.skillLevelIntermediate]: "中级",
[Key.skillLevelAdvanced]: "高级",
[Key.skillLevelExpert]: "专家",
[Key.skillExperience]: "经验",
[Key.skillYears]: "年",
[Key.skillMonths]: "个月",
[Key.skillsTotal]: "总技能数",
[Key.skillsExpert]: "专家级",
[Key.skillsAdvanced]: "高级",
[Key.skillsIntermediate]: "中级",
[Key.skillsBeginner]: "初级",
[Key.skillsAdvancedTitle]: "专业技能",
[Key.skillsProjects]: "相关项目",
[Key.skillsDistribution]: "技能分布",
[Key.skillsByLevel]: "按等级分布",
[Key.skillsByCategory]: "按分类分布",
[Key.noData]: "暂无数据",
// 时间线页面
[Key.timeline]: "时间线",
[Key.timelineSubtitle]: "我的成长历程和重要里程碑",
[Key.timelineEducation]: "教育经历",
[Key.timelineWork]: "工作经历",
[Key.timelineProject]: "项目经历",
[Key.timelineAchievement]: "成就荣誉",
[Key.timelinePresent]: "至今",
[Key.timelineLocation]: "地点",
[Key.timelineDescription]: "详细描述",
[Key.timelineMonths]: "个月",
[Key.timelineYears]: "年",
[Key.timelineTotal]: "总计",
[Key.timelineProjects]: "项目数",
[Key.timelineExperience]: "工作经验",
[Key.timelineCurrent]: "当前状态",
[Key.timelineHistory]: "历史记录",
[Key.timelineAchievements]: "成就荣誉",
[Key.timelineStartDate]: "开始日期",
[Key.timelineDuration]: "持续时间",
// 密码保护
[Key.passwordProtected]: "密码保护",
[Key.passwordProtectedTitle]: "此内容受密码保护",
[Key.passwordProtectedDescription]: "请输入密码以查看受保护的内容",
[Key.passwordPlaceholder]: "请输入密码",
[Key.passwordUnlock]: "解锁",
[Key.passwordUnlocking]: "解锁中...",
[Key.passwordIncorrect]: "密码错误,请重试",
[Key.passwordDecryptError]: "解密失败,请检查密码是否正确",
[Key.passwordRequired]: "请输入密码",
[Key.passwordVerifying]: "验证中...",
[Key.passwordDecryptFailed]: "解密失败,请检查密码",
[Key.passwordDecryptRetry]: "解密失败,请重试",
[Key.passwordUnlockButton]: "解锁",
[Key.copyFailed]: "复制失败:",
[Key.syntaxHighlightFailed]: "语法高亮失败:",
[Key.autoSyntaxHighlightFailed]: "自动语法高亮也失败:",
[Key.decryptionError]: "解密过程中发生错误:",
//最后编辑时间卡片
[Key.lastModifiedPrefix]: "距离上次编辑: ",
[Key.lastModifiedOutdated]: "部分信息可能已经过时",
[Key.year]: "年",
[Key.month]: "月",
[Key.day]: "天",
[Key.hour]: "小时",
[Key.minute]: "分",
[Key.second]: "秒",
// RSS 页面
[Key.rss]: "RSS 订阅",
[Key.rssDescription]: "订阅获取最新更新",
[Key.rssSubtitle]: "通过 RSS 订阅,第一时间获取最新文章和动态",
[Key.rssLink]: "RSS 链接",
[Key.rssCopyToReader]: "复制链接到你的 RSS 阅读器",
[Key.rssCopyLink]: "复制链接",
[Key.rssLatestPosts]: "最新文章",
[Key.rssWhatIsRSS]: "什么是 RSS",
[Key.rssWhatIsRSSDescription]:
"RSSReally Simple Syndication是一种用于发布经常更新内容的标准格式。通过 RSS你可以",
[Key.rssBenefit1]: "及时获取网站最新内容,无需手动访问",
[Key.rssBenefit2]: "在一个地方管理多个网站的订阅",
[Key.rssBenefit3]: "避免错过重要更新和文章",
[Key.rssBenefit4]: "享受无广告的纯净阅读体验",
[Key.rssHowToUse]: "推荐使用 Feedly、Inoreader 或其他 RSS 阅读器来订阅本站。",
[Key.rssCopied]: "RSS 链接已复制到剪贴板!",
[Key.rssCopyFailed]: "复制失败,请手动复制链接",
// Atom 页面
[Key.atom]: "Atom 订阅",
[Key.atomDescription]: "订阅获取最新更新",
[Key.atomSubtitle]: "通过 Atom 订阅,第一时间获取最新文章和动态",
[Key.atomLink]: "Atom 链接",
[Key.atomCopyToReader]: "复制链接到你的 Atom 阅读器",
[Key.atomCopyLink]: "复制链接",
[Key.atomLatestPosts]: "最新文章",
[Key.atomWhatIsAtom]: "什么是 Atom",
[Key.atomWhatIsAtomDescription]:
"Atom联合格式Atom Syndication Format是一个基于XML的标准用于描述订阅源及其信息项。通过 Atom你可以",
[Key.atomBenefit1]: "及时获取网站最新内容,无需手动访问",
[Key.atomBenefit2]: "在一个地方管理多个网站的订阅",
[Key.atomBenefit3]: "避免错过重要更新和文章",
[Key.atomBenefit4]: "享受无广告的纯净阅读体验",
[Key.atomHowToUse]:
"推荐使用 Feedly、Inoreader 或其他 Atom 阅读器来订阅本站。",
[Key.atomCopied]: "Atom 链接已复制到剪贴板!",
[Key.atomCopyFailed]: "复制失败,请手动复制链接",
// 壁纸模式
[Key.wallpaperBanner]: "横幅模式",
[Key.wallpaperFullscreen]: "全屏模式",
[Key.wallpaperNone]: "隐藏壁纸",
// 站点统计
[Key.siteStats]: "站点统计",
[Key.siteStatsPostCount]: "文章",
[Key.siteStatsCategoryCount]: "分类",
[Key.siteStatsTagCount]: "标签",
[Key.siteStatsTotalWords]: "总字数",
[Key.siteStatsRunningDays]: "运行时长",
[Key.siteStatsLastUpdate]: "最后活动",
[Key.siteStatsDaysAgo]: "{days} 天前",
[Key.siteStatsDays]: "{days} 天",
// 日历组件
[Key.calendarSunday]: "日",
[Key.calendarMonday]: "一",
[Key.calendarTuesday]: "二",
[Key.calendarWednesday]: "三",
[Key.calendarThursday]: "四",
[Key.calendarFriday]: "五",
[Key.calendarSaturday]: "六",
[Key.calendarJanuary]: "1月",
[Key.calendarFebruary]: "2月",
[Key.calendarMarch]: "3月",
[Key.calendarApril]: "4月",
[Key.calendarMay]: "5月",
[Key.calendarJune]: "6月",
[Key.calendarJuly]: "7月",
[Key.calendarAugust]: "8月",
[Key.calendarSeptember]: "9月",
[Key.calendarOctober]: "10月",
[Key.calendarNovember]: "11月",
[Key.calendarDecember]: "12月",
};

292
src/i18n/languages/zh_TW.ts Normal file
View File

@@ -0,0 +1,292 @@
import Key from "../i18nKey";
import type { Translation } from "../translation";
export const zh_TW: Translation = {
[Key.home]: "首頁",
[Key.about]: "關於我們",
[Key.archive]: "歸檔",
[Key.search]: "搜尋",
[Key.other]: "其他",
// 導航欄標題
[Key.navLinks]: "連結",
[Key.navMy]: "我的",
[Key.navAbout]: "關於",
[Key.navOthers]: "其他",
[Key.tags]: "標籤",
[Key.categories]: "分類",
[Key.recentPosts]: "最新文章",
[Key.postList]: "文章列表",
[Key.tableOfContents]: "目錄",
// 公告欄
[Key.announcement]: "公告",
[Key.announcementClose]: "關閉",
[Key.comments]: "評論",
[Key.friends]: "友鏈",
[Key.friendsSubtitle]: "發現更多優秀網站",
[Key.friendsSearchPlaceholder]: "搜索友鏈名稱或描述...",
[Key.friendsFilterAll]: "全部",
[Key.friendsNoResults]: "未找到匹配的友鏈",
[Key.friendsVisit]: "訪問",
[Key.friendsCopyLink]: "複製鏈接",
[Key.friendsCopySuccess]: "已複製",
[Key.friendsTags]: "標籤",
[Key.untitled]: "無標題",
[Key.uncategorized]: "未分類",
[Key.noTags]: "無標籤",
[Key.wordCount]: "字",
[Key.wordsCount]: "字",
[Key.minuteCount]: "分鐘",
[Key.minutesCount]: "分鐘",
[Key.postCount]: "篇文章",
[Key.postsCount]: "篇文章",
[Key.themeColor]: "主題色",
[Key.lightMode]: "亮色",
[Key.darkMode]: "暗色",
[Key.systemMode]: "跟隨系統",
[Key.more]: "更多",
[Key.author]: "作者",
[Key.publishedAt]: "發布於",
[Key.license]: "許可協議",
[Key.anime]: "追番",
[Key.diary]: "日記",
[Key.gallery]: "相冊",
// 設備頁面
[Key.devices]: "我的設備",
[Key.devicesSubtitle]: "這裡展示了我日常使用的各類設備",
// 番劇頁面
[Key.animeTitle]: "我的追番記錄",
[Key.animeSubtitle]: "記錄我的二次元之旅",
[Key.animeStatusWatching]: "在看",
[Key.animeStatusCompleted]: "看過",
[Key.animeStatusPlanned]: "想看",
[Key.animeStatusOnHold]: "擱置",
[Key.animeStatusDropped]: "拋棄",
[Key.animeFilterAll]: "全部",
[Key.animeYear]: "年份",
[Key.animeStudio]: "製作",
[Key.animeEmpty]: "暫無追番數據",
[Key.animeEmptyBangumi]: "請檢查 Bangumi 配置或網絡連接",
[Key.animeEmptyLocal]: "請在 src/data/anime.ts 文件中添加番劇信息",
// 短文頁面
[Key.diarySubtitle]: "隨時隨地,分享生活",
[Key.diaryCount]: "條短文",
[Key.diaryReply]: "回復",
[Key.diaryTips]: "只展示最近30條日記",
[Key.diaryMinutesAgo]: "分鐘前",
[Key.diaryHoursAgo]: "小時前",
[Key.diaryDaysAgo]: "天前",
// 404頁面
[Key.notFound]: "404",
[Key.notFoundTitle]: "頁面未找到",
[Key.notFoundDescription]: "抱歉,您訪問的頁面不存在或已被移動。",
[Key.backToHome]: "返回首頁",
// 音樂播放器
[Key.playlist]: "播放列表",
// 相冊頁面
[Key.albums]: "相冊",
[Key.albumsSubtitle]: "記錄生活中的美好瞬間",
[Key.albumsEmpty]: "暫無內容",
[Key.albumsEmptyDesc]: "還沒有創建任何相冊,快去添加一些美好的回憶吧!",
[Key.albumsBackToList]: "返回相冊",
[Key.albumsPhotoCount]: "張照片",
[Key.albumsPhotosCount]: "張照片",
// 項目展示頁面
[Key.projects]: "項目展示",
[Key.projectsSubtitle]: "我的開發項目作品集",
[Key.projectsAll]: "全部",
[Key.projectsWeb]: "網頁應用",
[Key.projectsMobile]: "移動應用",
[Key.projectsDesktop]: "桌面應用",
[Key.projectsOther]: "其他",
[Key.projectTechStack]: "技術棧",
[Key.projectLiveDemo]: "在線演示",
[Key.projectSourceCode]: "源代碼",
[Key.projectDescription]: "項目描述",
[Key.projectStatus]: "狀態",
[Key.projectStatusCompleted]: "已完成",
[Key.projectStatusInProgress]: "進行中",
[Key.projectStatusPlanned]: "計劃中",
[Key.projectsTotal]: "項目總數",
[Key.projectsCompleted]: "已完成",
[Key.projectsInProgress]: "進行中",
[Key.projectsTechStack]: "技術棧統計",
[Key.projectsFeatured]: "精選項目",
[Key.projectsPlanned]: "計劃中",
[Key.projectsDemo]: "線上展示",
[Key.projectsSource]: "原始碼",
[Key.projectsVisit]: "前往專案",
[Key.projectsGitHub]: "GitHub",
// [Key.projectsGitee]: "Gitee", // 移除 Gitee 支援
// RSS 頁面
[Key.rss]: "RSS 訂閱",
[Key.rssDescription]: "訂閱獲取最新更新",
[Key.rssSubtitle]: "通過 RSS 訂閱,第一時間獲取最新文章和動態",
[Key.rssLink]: "RSS 鏈接",
[Key.rssCopyToReader]: "複製鏈接到你的 RSS 閱讀器",
[Key.rssCopyLink]: "複製鏈接",
[Key.rssLatestPosts]: "最新文章",
[Key.rssWhatIsRSS]: "什麼是 RSS",
[Key.rssWhatIsRSSDescription]:
"RSSReally Simple Syndication是一種用於發布經常更新內容的標準格式。通過 RSS你可以",
[Key.rssBenefit1]: "及時獲取網站最新內容,無需手動訪問",
[Key.rssBenefit2]: "在一個地方管理多個網站的訂閱",
[Key.rssBenefit3]: "避免錯過重要更新和文章",
[Key.rssBenefit4]: "享受無廣告的純淨閱讀體驗",
[Key.rssHowToUse]: "推薦使用 Feedly、Inoreader 或其他 RSS 閱讀器來訂閱本站。",
[Key.rssCopied]: "RSS 鏈接已複製到剪貼板!",
[Key.rssCopyFailed]: "複製失敗,請手動複製鏈接",
//Atom Feed 頁面
[Key.atom]: "Atom 訂閱",
[Key.atomDescription]: "訂閱獲取最新更新",
[Key.atomSubtitle]: "通過 Atom 訂閱,第一時間獲取最新文章和動態",
[Key.atomLink]: "Atom 鏈接",
[Key.atomCopyToReader]: "複製鏈接到你的 Atom 閱讀器",
[Key.atomCopyLink]: "複製鏈接",
[Key.atomLatestPosts]: "最新文章",
[Key.atomWhatIsAtom]: "什麼是 Atom",
[Key.atomWhatIsAtomDescription]:
"Atom聯合格式Atom Syndication Format是一個基於XML的標準用於描述訂閱源及其信息項。通過 Atom你可以",
[Key.atomBenefit1]: "及時獲取網站最新內容,無需手動訪問",
[Key.atomBenefit2]: "在一個地方管理多個網站的訂閱",
[Key.atomBenefit3]: "避免錯過重要更新和文章",
[Key.atomBenefit4]: "享受無廣告的純淨閱讀體驗",
[Key.atomHowToUse]:
"推薦使用 Feedly、Inoreader 或其他 Atom 閱讀器來訂閱本站。",
[Key.atomCopied]: "Atom 鏈接已複製到剪貼板!",
[Key.atomCopyFailed]: "複製失敗,請手動複製鏈接",
// 技能展示頁面
[Key.skills]: "技能展示",
[Key.skillsSubtitle]: "我的技術技能和專業知識",
[Key.skillsFrontend]: "前端開發",
[Key.skillsBackend]: "後端開發",
[Key.skillsDatabase]: "數據庫",
[Key.skillsTools]: "開發工具",
[Key.skillsOther]: "其他技能",
[Key.skillLevel]: "熟練度",
[Key.skillLevelBeginner]: "初學者",
[Key.skillLevelIntermediate]: "中級",
[Key.skillLevelAdvanced]: "高級",
[Key.skillLevelExpert]: "專家",
[Key.skillExperience]: "經驗",
[Key.skillYears]: "年",
[Key.skillMonths]: "個月",
[Key.skillsTotal]: "總技能數",
[Key.skillsExpert]: "專家級",
[Key.skillsAdvanced]: "高級",
[Key.skillsIntermediate]: "中級",
[Key.skillsBeginner]: "初級",
[Key.skillsAdvancedTitle]: "專業技能",
[Key.skillsProjects]: "相關項目",
[Key.skillsDistribution]: "技能分布",
[Key.skillsByLevel]: "按等級分布",
[Key.skillsByCategory]: "按分類分布",
// 時間線頁面
[Key.timeline]: "時間線",
[Key.timelineSubtitle]: "我的成長歷程和重要里程碑",
[Key.timelineEducation]: "教育經歷",
[Key.timelineWork]: "工作經歷",
[Key.timelineProject]: "項目經歷",
[Key.timelineAchievement]: "成就榮譽",
[Key.timelinePresent]: "至今",
[Key.timelineLocation]: "地點",
[Key.timelineDescription]: "詳細描述",
[Key.timelineMonths]: "個月",
[Key.timelineYears]: "年",
[Key.timelineTotal]: "總計",
[Key.timelineProjects]: "項目數",
[Key.timelineExperience]: "工作經驗",
[Key.timelineCurrent]: "當前狀態",
[Key.timelineHistory]: "歷史記錄",
[Key.timelineAchievements]: "成就榮譽",
[Key.timelineStartDate]: "開始日期",
[Key.timelineDuration]: "持續時間",
// 其他
[Key.noData]: "暫無數據",
// 密碼保護
[Key.passwordProtected]: "密碼保護",
[Key.passwordProtectedTitle]: "此內容受密碼保護",
[Key.passwordProtectedDescription]: "請輸入密碼以查看受保護的內容",
[Key.passwordPlaceholder]: "請輸入密碼",
[Key.passwordUnlock]: "解鎖",
[Key.passwordUnlocking]: "解鎖中...",
[Key.passwordIncorrect]: "密碼錯誤,請重試",
[Key.passwordDecryptError]: "解密失敗,請檢查密碼是否正確",
[Key.passwordRequired]: "請輸入密碼",
[Key.passwordVerifying]: "驗證中...",
[Key.passwordDecryptFailed]: "解密失敗,請檢查密碼",
[Key.passwordDecryptRetry]: "解密失敗,請重試",
[Key.passwordUnlockButton]: "解鎖",
[Key.copyFailed]: "複製失敗:",
[Key.syntaxHighlightFailed]: "語法高亮失敗:",
[Key.autoSyntaxHighlightFailed]: "自動語法高亮也失敗:",
[Key.decryptionError]: "解密過程中發生錯誤:",
//最後編輯時間卡片
[Key.lastModifiedPrefix]: "距離上次編輯: ",
[Key.lastModifiedOutdated]: "部分信息可能已經過時",
[Key.year]: "年",
[Key.month]: "月",
[Key.day]: "天",
[Key.hour]: "小時",
[Key.minute]: "分",
[Key.second]: "秒",
// 壁紙模式
[Key.wallpaperBanner]: "橫幅模式",
[Key.wallpaperFullscreen]: "全屏模式",
[Key.wallpaperNone]: "隱藏壁紙",
// 站點統計
[Key.siteStats]: "站點統計",
[Key.siteStatsPostCount]: "文章數",
[Key.siteStatsCategoryCount]: "分類數",
[Key.siteStatsTagCount]: "標籤數",
[Key.siteStatsTotalWords]: "總字數",
[Key.siteStatsRunningDays]: "運行天數",
[Key.siteStatsLastUpdate]: "最後活動",
[Key.siteStatsDaysAgo]: "{days} 天前",
[Key.siteStatsDays]: "{days} 天",
// 日曆組件
[Key.calendarSunday]: "日",
[Key.calendarMonday]: "一",
[Key.calendarTuesday]: "二",
[Key.calendarWednesday]: "三",
[Key.calendarThursday]: "四",
[Key.calendarFriday]: "五",
[Key.calendarSaturday]: "六",
[Key.calendarJanuary]: "1月",
[Key.calendarFebruary]: "2月",
[Key.calendarMarch]: "3月",
[Key.calendarApril]: "4月",
[Key.calendarMay]: "5月",
[Key.calendarJune]: "6月",
[Key.calendarJuly]: "7月",
[Key.calendarAugust]: "8月",
[Key.calendarSeptember]: "9月",
[Key.calendarOctober]: "10月",
[Key.calendarNovember]: "11月",
[Key.calendarDecember]: "12月",
};

32
src/i18n/translation.ts Normal file
View File

@@ -0,0 +1,32 @@
import { siteConfig } from "../config";
import type I18nKey from "./i18nKey";
import { en } from "./languages/en";
import { ja } from "./languages/ja";
import { zh_CN } from "./languages/zh_CN";
import { zh_TW } from "./languages/zh_TW";
export type Translation = {
[K in I18nKey]: string;
};
const defaultTranslation = en;
const map: { [key: string]: Translation } = {
en: en,
en_us: en,
en_gb: en,
en_au: en,
zh_cn: zh_CN,
zh_tw: zh_TW,
ja: ja,
ja_jp: ja,
};
export function getTranslation(lang: string): Translation {
return map[lang.toLowerCase()] || defaultTranslation;
}
export function i18n(key: I18nKey): string {
const lang = siteConfig.lang || "en";
return getTranslation(lang)[key];
}

1166
src/layouts/Layout.astro Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

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

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

@@ -0,0 +1,23 @@
---
import type { GetStaticPaths } from "astro";
import Pagination from "../components/control/Pagination.astro";
import PostPage from "../components/PostPage.astro";
import { PAGE_SIZE } from "../constants/constants";
import MainGridLayout from "../layouts/MainGridLayout.astro";
import { getSortedPosts } from "../utils/content-utils";
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;
---
<MainGridLayout>
<PostPage page={page}></PostPage>
<Pagination class="mx-auto onload-animation" page={page} style={`animation-delay: calc(var(--content-delay) + ${(len)*50}ms)`}></Pagination>
</MainGridLayout>

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

@@ -0,0 +1,41 @@
---
import { getEntry, render } from "astro:content";
import Markdown from "@components/misc/Markdown.astro";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
import MainGridLayout from "../layouts/MainGridLayout.astro";
const aboutPost = await getEntry("spec", "about");
if (!aboutPost) {
throw new Error("About page content not found");
}
const { Content } = await render(aboutPost);
const title = i18n(I18nKey.about);
---
<MainGridLayout title={title}>
<!-- 引入右侧边栏布局管理器 -->
<script>
import('../scripts/right-sidebar-layout.js');
</script>
<div class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full">
<!-- 页面标题 -->
<div class="flex flex-col items-start justify-center mb-8">
<h1 class="text-4xl font-bold text-black/90 dark:text-white/90 mb-2 relative
before:w-1 before:h-8 before:rounded-md before:bg-[var(--primary)]
before:absolute before:top-1/2 before:-translate-y-1/2 before:-left-4">
{title}
</h1>
</div>
<!-- 从MD文件读取的内容 -->
<Markdown class="mt-2">
<Content />
</Markdown>
</div>
</div>
</MainGridLayout>

149
src/pages/albums.astro Normal file
View File

@@ -0,0 +1,149 @@
---
import { siteConfig } from "../config";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
import MainGridLayout from "../layouts/MainGridLayout.astro";
import { scanAlbums } from "../utils/album-scanner";
// 检查相册页面是否启用
if (!siteConfig.featurePages.albums) {
return Astro.redirect("/404/");
}
// 获取所有相册
const albumsData = await scanAlbums();
---
<MainGridLayout title={i18n(I18nKey.albums)} description={i18n(I18nKey.albumsSubtitle)}>
<!-- 引入右侧边栏布局管理器 -->
<script>
import('../scripts/right-sidebar-layout.js');
</script>
<div class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full">
<!-- 页面标题 -->
<header class="mb-8">
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-3 relative
before:w-1 before:h-8 before:rounded-md before:bg-[var(--primary)]
before:absolute before:top-1/2 before:-translate-y-1/2 before:-left-4">
{i18n(I18nKey.albums)}
</h1>
<p class="text-neutral-600 dark:text-neutral-400">
{i18n(I18nKey.albumsSubtitle)}
</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-transform 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-[var(--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>
</MainGridLayout>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.album-card:hover {
transform: translateY(-2px);
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.album-card {
animation: fadeInUp 0.3s ease-out;
}
</style>

View File

@@ -0,0 +1,183 @@
---
import { siteConfig } from "../../../config";
import I18nKey from "../../../i18n/i18nKey";
import { i18n } from "../../../i18n/translation";
import MainGridLayout from "../../../layouts/MainGridLayout.astro";
// import type { GetStaticPaths } from "astro";
import type { AlbumGroup, Photo } from "../../../types/album";
import { scanAlbums } from "../../../utils/album-scanner";
// 检查相册页面是否启用
if (!siteConfig.featurePages.albums) {
return Astro.redirect("/404/");
}
export const getStaticPaths = async () => {
const albumsData = await scanAlbums();
return albumsData.map((album) => ({
params: { id: album.id },
props: { album },
}));
};
interface Props {
album: AlbumGroup;
}
const { album } = Astro.props;
if (!album) {
return Astro.redirect("/404/");
}
---
<MainGridLayout title={album.title} description={album.description || album.title}>
<div class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full">
<!-- 返回按钮 -->
<div class="mb-6">
<a
href="/albums/"
class="inline-flex items-center gap-2 text-neutral-600 dark:text-neutral-400 hover:text-[var(--primary)] transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
{i18n(I18nKey.albumsBackToList)}
</a>
</div>
<!-- 相册标题信息 -->
<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: string) => (
<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: Photo, _index: number) => (
<figure class="photo-item group relative overflow-hidden rounded-lg">
<a
href="javascript:void(0)"
data-src={photo.src}
data-fancybox="gallery"
data-caption={photo.title || photo.alt}
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>
</MainGridLayout>
<script>
// 确保Fancybox正确处理相册图片
document.addEventListener('DOMContentLoaded', () => {
console.log('=== 相册页面加载完成 ===');
// 检查页面中的图片元素
const albumImages = document.querySelectorAll('.moment-images img, .photo-gallery img');
console.log('相册图片数量:', albumImages.length);
// 预加载图片以确保正确显示
albumImages.forEach((img) => {
if (img instanceof HTMLImageElement) {
const preloadImg = new Image();
preloadImg.src = img.src;
}
});
});
</script>
<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: 1024px) {
.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>

918
src/pages/anime.astro Normal file
View File

@@ -0,0 +1,918 @@
---
import ImageWrapper from "../components/misc/ImageWrapper.astro";
import { sidebarLayoutConfig, siteConfig } from "../config";
import localAnimeList from "../data/anime";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
import MainGridLayout from "../layouts/MainGridLayout.astro";
// 检查番剧页面是否启用
if (!siteConfig.featurePages.anime) {
return Astro.redirect("/404/");
}
// 检查番剧页面是否启用
if (!siteConfig.featurePages.anime) {
return Astro.redirect("/404/");
}
// 检查是否为双侧边栏模式
const isBothSidebarMode = sidebarLayoutConfig.position === "both";
// Bangumi API配置
const BANGUMI_USER_ID = siteConfig.bangumi?.userId || "your-user-id"; // 用户ID
const BANGUMI_API_BASE = "https://api.bgm.tv";
// 获取番剧数据模式(不需要更改请在主配置src/config.ts中修改)
const ANIME_MODE = siteConfig.anime?.mode || "bangumi";
// 获取单个条目相关人员信息
async function fetchSubjectPersons(subjectId: number) {
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,
) {
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: item.subject?.id
? `https://bgm.tv/subject/${item.subject.id}`
: "#",
progress: progress,
totalEpisodes: totalEpisodes,
startDate: item.subject?.date || "",
endDate: item.subject?.date || "",
};
}),
);
return detailedItems;
}
// 根据模式获取番剧列表
let animeList: typeof localAnimeList = [];
if (ANIME_MODE === "local") {
// 使用本地配置
animeList = localAnimeList;
} else {
// 使用Bangumi API默认模式
// 获取与处理在看列表type=3想看列表type=1看过列表type=2搁置列表type=4抛弃列表type=5
const watchingData = await fetchBangumiCollection(BANGUMI_USER_ID, 2, 3);
const plannedData = await fetchBangumiCollection(BANGUMI_USER_ID, 2, 1);
const completedData = await fetchBangumiCollection(BANGUMI_USER_ID, 2, 2);
const onHoldData = await fetchBangumiCollection(BANGUMI_USER_ID, 2, 4);
const droppedData = await fetchBangumiCollection(BANGUMI_USER_ID, 2, 5);
const watchingList = watchingData
? await processBangumiData(watchingData, "watching")
: [];
const plannedList = plannedData
? await processBangumiData(plannedData, "planned")
: [];
const completedList = completedData
? await processBangumiData(completedData, "completed")
: [];
const onHoldList = onHoldData
? await processBangumiData(onHoldData, "onhold")
: [];
const droppedList = droppedData
? await processBangumiData(droppedData, "dropped")
: [];
animeList = [
...watchingList,
...plannedList,
...completedList,
...onHoldList,
...droppedList,
];
}
// 获取状态的翻译文本和样式
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: "⏰",
};
case "onhold":
return {
text: i18n(I18nKey.animeStatusOnHold),
class:
"bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300",
icon: "⏸",
};
case "dropped":
return {
text: i18n(I18nKey.animeStatusDropped),
class: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-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);
// })(),
// };
---
<MainGridLayout title={i18n(I18nKey.anime)} description={i18n(I18nKey.animeSubtitle)}>
<div class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full">
<!-- 页面标题 -->
<div class="relative w-full mb-8">
<div class="mb-6">
<h1 class="text-4xl font-bold text-black/90 dark:text-white/90 mb-2 relative
before:w-1 before:h-8 before:rounded-md before:bg-[var(--primary)]
before:absolute before:top-1/2 before:-translate-y-1/2 before:-left-4">
{i18n(I18nKey.animeTitle)}
</h1>
<p class="text-black/75 dark:text-white/75">{i18n(I18nKey.animeSubtitle)}</p>
</div>
<!-- 过滤按钮 - 参考Firefly设计 -->
<div class="mb-6">
<div class="filter-container flex flex-wrap gap-2">
<button class="filter-tag active" data-status="all">{i18n(I18nKey.animeFilterAll)}</button>
<button class="filter-tag" data-status="watching">{i18n(I18nKey.animeStatusWatching)}</button>
<button class="filter-tag" data-status="planned">{i18n(I18nKey.animeStatusPlanned)}</button>
<button class="filter-tag" data-status="completed">{i18n(I18nKey.animeStatusCompleted)}</button>
{ANIME_MODE === 'bangumi' && (
<>
<button class="filter-tag" data-status="onhold">{i18n(I18nKey.animeStatusOnHold)}</button>
<button class="filter-tag" data-status="dropped">{i18n(I18nKey.animeStatusDropped)}</button>
</>
)}
</div>
</div>
</div>
<!-- 动漫列表 -->
<div class="mb-8">
{ANIME_MODE !== 'local' && BANGUMI_USER_ID === 'your-user-id' ? (
<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">
请在 src/config.ts 文件中设置你的 Bangumi 用户ID
</p>
</div>
) : animeList.length > 0 ? (
<div id="anime-list-container" class={`anime-grid-container grid gap-4 md:gap-6 list-mode ${
isBothSidebarMode
? "both-sidebar"
: "single-sidebar"
}`}>
{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-[var(--card-bg)] border border-[var(--line-divider)] rounded-[var(--radius-large)] overflow-hidden transition-all duration-300 hover:shadow-lg hover:scale-[1.02]" data-anime-status={anime.status}>
<!-- 封面区域 - 竖屏比例 -->
<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"
/>
<div class="absolute inset-0 bg-gradient-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-gradient-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-gradient-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-[var(--btn-regular-bg)] text-black/70 dark:text-white/70 rounded 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">
{ANIME_MODE === 'local' ? i18n(I18nKey.animeEmptyLocal) : i18n(I18nKey.animeEmptyBangumi)}
</p>
</div>
)}
</div>
</div>
</div>
<script is:inline define:vars={{ isBothSidebarMode }}>
// 动画列表布局切换脚本
(function() {
// 使用更可靠的初始化检查机制
function initAnimeLayout() {
const animeListContainer = document.getElementById("anime-list-container");
if (!animeListContainer) {
return false; // 容器未找到,继续重试
}
// 获取当前布局设置
const currentLayout = localStorage.getItem("postListLayout") || "list";
// 初始化布局(不依赖 window.layoutManager
updateAnimeListLayout(currentLayout);
return true; // 初始化成功
}
// 尝试初始化,带有指数退避的重试机制
let retryCount = 0;
const maxRetries = 10;
function tryInit() {
if (initAnimeLayout()) return;
if (retryCount < maxRetries) {
retryCount++;
const delay = Math.min(100 * Math.pow(1.5, retryCount), 1000);
setTimeout(tryInit, delay);
} else {
console.warn("Failed to initialize anime layout after multiple attempts");
// 最后尝试一次强制初始化
setTimeout(() => {
const animeListContainer = document.getElementById("anime-list-container");
if (animeListContainer) {
const currentLayout = localStorage.getItem("postListLayout") || "list";
updateAnimeListLayout(currentLayout);
}
}, 2000);
}
}
// 立即尝试第一次
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', tryInit);
} else {
tryInit();
}
function updateAnimeListLayout(layout) {
const animeListContainer = document.getElementById("anime-list-container");
if (!animeListContainer) return;
// 避免重复执行
if (animeListContainer.dataset.currentLayout === layout) return;
animeListContainer.dataset.currentLayout = layout;
// FLIP 动画技术实现布局切换动画
// First: 记录所有卡片的初始位置
const animeItems = Array.from(document.querySelectorAll('[data-anime-status]'));
const firstPositions = new Map();
animeItems.forEach(item => {
const rect = item.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
firstPositions.set(item, {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height
});
}
});
// 暂时禁用过渡
const style = document.createElement('style');
style.innerHTML = `
.anime-item, .anime-item * {
transition: none !important;
}
`;
document.head.appendChild(style);
// 立即应用布局更改
animeListContainer.classList.remove("list-mode", "grid-mode");
if (layout === "grid") {
animeListContainer.classList.add("grid-mode");
animeListContainer.classList.add("grid", "grid-cols-1", "md:grid-cols-2", "lg:grid-cols-3");
animeListContainer.classList.remove("flex", "flex-col");
const rightSidebar = document.querySelector('.right-sidebar-container');
if (rightSidebar) {
rightSidebar.style.display = 'none';
rightSidebar.classList.add('hidden-in-grid-mode');
}
const mainGrid = document.getElementById('main-grid');
if (mainGrid) {
mainGrid.style.gridTemplateColumns = '17.5rem 1fr';
mainGrid.classList.add('two-column-layout');
mainGrid.setAttribute('data-layout-mode', 'grid');
}
} else {
animeListContainer.classList.add("list-mode");
animeListContainer.classList.add("flex", "flex-col");
animeListContainer.classList.remove("grid", "grid-cols-1", "md:grid-cols-2", "lg:grid-cols-3");
const rightSidebar = document.querySelector('.right-sidebar-container');
if (rightSidebar) {
rightSidebar.style.display = '';
rightSidebar.classList.remove('hidden-in-grid-mode');
}
const mainGrid = document.getElementById('main-grid');
if (mainGrid) {
mainGrid.style.gridTemplateColumns = '';
mainGrid.classList.remove('two-column-layout');
mainGrid.setAttribute('data-layout-mode', 'list');
}
}
// 强制重排
void animeListContainer.offsetHeight;
// 在下一帧执行 Invert & Play
requestAnimationFrame(() => {
// 移除禁用过渡的样式
if (style.parentNode) {
style.parentNode.removeChild(style);
}
animeItems.forEach(item => {
const first = firstPositions.get(item);
if (!first) return;
const last = item.getBoundingClientRect();
const deltaX = first.left - last.left;
const deltaY = first.top - last.top;
const deltaW = first.width / last.width;
const deltaH = first.height / last.height;
if (Math.abs(deltaX) < 1 && Math.abs(deltaY) < 1 && Math.abs(deltaW - 1) < 0.01 && Math.abs(deltaH - 1) < 0.01) {
return;
}
// Invert: 立即应用反向变换此时没有过渡因为上面虽然移除了style但新样式生效需要时间或者我们可以显式设置 transition: none
item.style.transition = 'none';
item.style.transformOrigin = 'top left';
item.style.transform = `translate(${deltaX}px, ${deltaY}px) scale(${deltaW}, ${deltaH})`;
});
// 强制重排
void animeListContainer.offsetHeight;
// Play: 启用过渡并移除变换
requestAnimationFrame(() => {
animeItems.forEach(item => {
if (!firstPositions.has(item)) return;
item.style.transition = 'transform 0.5s cubic-bezier(0.25, 0.8, 0.25, 1)';
item.style.transform = '';
});
// 动画结束后清理
setTimeout(() => {
animeItems.forEach(item => {
item.style.transition = '';
item.style.transformOrigin = '';
item.style.transform = '';
});
}, 500);
});
});
}
// 监听布局变化事件
window.addEventListener("layoutChange", (event) => {
updateAnimeListLayout(event.detail.layout);
});
})();
</script>
<!-- 过滤按钮样式 -->
<style>
/* 容器查询支持 */
.card-base {
container-type: inline-size;
}
/* 番剧网格容器 - 使用容器查询实现响应式列数 */
.anime-grid-container {
/* 基础配置 - 移动端 */
display: grid;
grid-template-columns: repeat(2, 1fr);
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 单侧栏模式 - 所有情况下都显示5列 */
.anime-grid-container.single-sidebar {
@container (min-width: 900px) {
grid-template-columns: repeat(5, 1fr);
}
@container (min-width: 600px) and (max-width: 899px) {
grid-template-columns: repeat(3, 1fr);
}
@container (max-width: 599px) {
grid-template-columns: repeat(2, 1fr);
}
}
/* 双侧栏模式 */
/* list模式显示4列grid模式显示5列 */
.anime-grid-container.both-sidebar {
/* grid模式宽度足够大 - 显示5列 */
@container (min-width: 950px) {
grid-template-columns: repeat(5, 1fr);
}
/* list模式宽度中等 - 显示4列 */
@container (min-width: 650px) and (max-width: 949px) {
grid-template-columns: repeat(4, 1fr);
}
@container (min-width: 480px) and (max-width: 649px) {
grid-template-columns: repeat(3, 1fr);
}
@container (max-width: 479px) {
grid-template-columns: repeat(2, 1fr);
}
}
.filter-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.filter-tag {
padding: 0.5rem 1rem;
border: 1px solid var(--line-divider);
border-radius: var(--radius-large);
background: var(--btn-regular-bg);
color: var(--btn-content);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.filter-tag:hover:not(.active) {
background: var(--btn-hover-bg);
border-color: var(--primary);
transform: translateY(-1px);
}
.filter-tag.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.filter-tag.active:hover {
background: var(--primary) !important;
color: white !important;
border-color: var(--primary) !important;
transform: translateY(-1px);
}
/* 动画卡片样式 - 使用 FLIP 动画技术实现洗牌效果 */
[data-anime-status] {
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 网格模式下的悬停效果增强 */
#anime-list-container.grid-mode [data-anime-status]:hover {
transform: translateY(-8px) scale(1.02);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
z-index: 10;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 列表模式下的悬停效果增强 */
#anime-list-container.list-mode [data-anime-status]:hover {
transform: translateX(8px) scale(1.01);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
z-index: 10;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 完全隐藏 */
[data-anime-status].anime-hidden {
display: none;
}
/* 动画中的卡片 - 使用绝对定位进行位置过渡 */
[data-anime-status].anime-animating {
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 淡出效果(用于要隐藏的卡片) - 缩小并淡出 */
[data-anime-status].anime-fade-out {
opacity: 0;
transform: scale(0.8);
pointer-events: none;
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 淡入效果(用于新显示的卡片) - 从小放大并淡入 */
[data-anime-status].anime-fade-in {
opacity: 0;
transform: scale(0.8);
}
[data-anime-status].anime-fade-in-active {
opacity: 1;
transform: scale(1);
}
</style>
<!-- 过滤功能脚本 -->
<script is:inline>
// 事件监听器存储,用于防止重复绑定
if (typeof window.animeFilterEventListeners === 'undefined') {
window.animeFilterEventListeners = [];
}
// 初始化过滤功能
function initFilterButtons() {
const filterTags = document.querySelectorAll('.filter-tag');
// 移除之前的事件监听器
window.animeFilterEventListeners.forEach(listener => {
const [element, type, handler] = listener;
element.removeEventListener(type, handler);
});
window.animeFilterEventListeners = [];
// 过滤功能 - 使用 FLIP 技术实现洗牌动画
filterTags.forEach(tag => {
const clickHandler = function() {
// 防止重复点击
if (this.classList.contains('active')) return;
// 移除所有active类
filterTags.forEach(t => t.classList.remove('active'));
// 添加active类到当前点击的标签
this.classList.add('active');
const status = this.getAttribute('data-status');
const animeItems = Array.from(document.querySelectorAll('[data-anime-status]'));
// FLIP 动画技术
// First: 记录所有卡片的初始位置
const firstPositions = new Map();
animeItems.forEach(item => {
const rect = item.getBoundingClientRect();
firstPositions.set(item, {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height
});
});
// 分类卡片
const itemsToHide = [];
const itemsToShow = [];
const itemsToKeep = [];
animeItems.forEach(item => {
const itemStatus = item.getAttribute('data-anime-status');
const shouldShow = status === 'all' || itemStatus === status;
const isCurrentlyVisible = !item.classList.contains('anime-hidden');
if (shouldShow) {
if (isCurrentlyVisible) {
itemsToKeep.push(item);
} else {
itemsToShow.push(item);
}
} else {
if (isCurrentlyVisible) {
itemsToHide.push(item);
}
}
});
// 为要隐藏的卡片添加淡出效果
itemsToHide.forEach(item => {
item.classList.add('anime-fade-out');
});
// 等待淡出完成
setTimeout(() => {
// Last: 完全隐藏要移除的卡片
itemsToHide.forEach(item => {
item.classList.add('anime-hidden');
item.classList.remove('anime-fade-out');
});
// 显示新卡片但先设为透明和缩小(在它们的最终位置)
itemsToShow.forEach(item => {
item.classList.remove('anime-hidden');
item.classList.add('anime-fade-in');
item.style.opacity = '0';
item.style.transform = 'scale(0.8)';
item.style.transition = 'none';
});
// 等待布局更新
requestAnimationFrame(() => {
// 记录所有卡片的最终位置(包括新显示的卡片)
const lastPositions = new Map();
[...itemsToKeep, ...itemsToShow].forEach(item => {
const rect = item.getBoundingClientRect();
lastPositions.set(item, {
left: rect.left,
top: rect.top
});
});
// Invert: 只对保持显示的卡片应用反向变换
itemsToKeep.forEach(item => {
const first = firstPositions.get(item);
const last = lastPositions.get(item);
if (first && last) {
const deltaX = first.left - last.left;
const deltaY = first.top - last.top;
// 立即应用反向变换(不使用过渡)
item.style.transition = 'none';
item.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
}
});
// Play: 触发动画
requestAnimationFrame(() => {
const allItems = [...itemsToKeep, ...itemsToShow];
allItems.forEach((item, index) => {
// 错开动画开始时间,创建波浪效果
setTimeout(() => {
item.classList.add('anime-animating');
// 保持显示的卡片:移动到最终位置
if (itemsToKeep.includes(item)) {
item.style.transition = '';
item.style.transform = '';
}
// 新显示的卡片:在当前位置淡入并放大
if (itemsToShow.includes(item)) {
item.classList.remove('anime-fade-in');
item.classList.add('anime-fade-in-active');
item.style.transition = '';
item.style.opacity = '1';
item.style.transform = 'scale(1)';
}
}, index * 20); // 每个卡片延迟20ms
});
// 动画完成后清理
setTimeout(() => {
allItems.forEach(item => {
item.classList.remove('anime-animating', 'anime-fade-in-active');
item.style.transition = '';
item.style.transform = '';
item.style.opacity = '';
});
}, 500 + allItems.length * 20); // 等待所有动画完成
});
});
}, 300); // 等待淡出动画完成
};
tag.addEventListener('click', clickHandler);
window.animeFilterEventListeners.push([tag, 'click', clickHandler]);
});
console.log('Filter buttons initialized with', filterTags.length, 'buttons');
}
// 页面加载时初始化
document.addEventListener('DOMContentLoaded', initFilterButtons);
// Swup页面切换后重新初始化
function setupSwupListeners() {
if (window.swup) {
// 使用Swup hooks监听页面切换事件
window.swup.hooks.on('content:replace', function() {
console.log('Swup content replaced - reinitializing filter buttons');
setTimeout(initFilterButtons, 150);
});
window.swup.hooks.on('page:view', function() {
console.log('Swup page view - reinitializing filter buttons');
setTimeout(initFilterButtons, 150);
});
window.swup.hooks.on('animation:in:end', function() {
console.log('Swup animation ended - reinitializing filter buttons');
setTimeout(initFilterButtons, 200);
});
}
}
// 初始化Swup监听器
if (typeof window !== 'undefined') {
if (window.swup) {
setupSwupListeners();
} else {
// 如果Swup尚未初始化监听启用事件
document.addEventListener('swup:enable', function() {
console.log('Swup enabled - setting up listeners');
setupSwupListeners();
});
}
}
</script>
</MainGridLayout>

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

@@ -0,0 +1,18 @@
---
import ArchivePanel from "@components/ArchivePanel.svelte";
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import MainGridLayout from "@layouts/MainGridLayout.astro";
import { getSortedPostsList } from "../utils/content-utils";
const sortedPostsList = await getSortedPostsList();
---
<MainGridLayout title={i18n(I18nKey.archive)}>
<!-- 引入右侧边栏布局管理器 -->
<script>
import('../scripts/right-sidebar-layout.js');
</script>
<ArchivePanel sortedPosts={sortedPostsList} client:only="svelte"></ArchivePanel>
</MainGridLayout>

126
src/pages/atom.astro Normal file
View File

@@ -0,0 +1,126 @@
---
import MainGridLayout from "@layouts/MainGridLayout.astro";
import { getSortedPosts } from "@utils/content-utils";
import { formatDateToYYYYMMDD } from "@utils/date-utils";
import { Icon } from "astro-icon/components";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
const posts = (await getSortedPosts()).filter((post) => !post.data.encrypted);
const recentPosts = posts.slice(0, 6);
---
<MainGridLayout title={i18n(I18nKey.atom)} description={i18n(I18nKey.atomDescription)}>
<div class="onload-animation">
<!-- Atom 标题和介绍 -->
<div class="card-base rounded-[var(--radius-large)] p-8 mb-6">
<div class="text-center">
<div class="inline-flex items-center justify-center w-16 h-16 bg-[var(--primary)] rounded-2xl mb-4">
<Icon name="material-symbols:rss-feed" class="text-white text-3xl" />
</div>
<h1 class="text-3xl font-bold text-[var(--primary)] mb-3">{i18n(I18nKey.atom)}</h1>
<p class="text-75 max-w-2xl mx-auto">
{i18n(I18nKey.atomSubtitle)}
</p>
</div>
</div>
<!-- Atom 链接复制区域 -->
<div class="card-base rounded-[var(--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-[var(--primary)] rounded-xl flex items-center justify-center mr-4">
<Icon name="material-symbols:link" class="text-white text-xl" />
</div>
<div>
<h3 class="font-semibold text-90 mb-1">{i18n(I18nKey.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-[var(--card-bg)] px-3 py-2 rounded-lg text-sm font-mono text-75 border border-[var(--line-divider)] break-all">
{Astro.site}atom.xml
</code>
<button
id="copy-atom-btn"
class="px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-80 transition-all duration-200 font-medium text-sm whitespace-nowrap"
data-url={`${Astro.site}atom.xml`}
>
{i18n(I18nKey.atomCopyLink) || "Copy Link"}
</button>
</div>
</div>
</div>
<!-- 最新文章预览 -->
<div class="card-base rounded-[var(--radius-large)] p-6 mb-6">
<h2 class="text-xl font-bold text-90 mb-4 flex items-center">
<Icon name="material-symbols:article" class="mr-2 text-[var(--primary)]" />
{i18n(I18nKey.atomLatestPosts)}
</h2>
<div class="space-y-4">
{recentPosts.map((post) => (
<article class="bg-[var(--card-bg)] rounded-xl p-4 border border-[var(--line-divider)] hover:border-[var(--primary)] transition-all duration-300">
<h3 class="text-lg font-semibold text-90 mb-2 hover:text-[var(--primary)] transition-colors">
<a href={`/posts/${post.id}/`} class="hover:underline">
{post.data.title}
</a>
</h3>
{post.data.description && (
<p class="text-75 mb-3 line-clamp-2">
{post.data.description}
</p>
)}
<div class="flex items-center gap-4 text-sm text-60">
<time datetime={post.data.published.toISOString()} class="text-75">
{formatDateToYYYYMMDD(post.data.published)}
</time>
</div>
</article>
))}
</div>
</div>
<!-- Atom 说明 -->
<div class="card-base rounded-[var(--radius-large)] p-6">
<h2 class="text-xl font-bold text-90 mb-4 flex items-center">
<Icon name="material-symbols:help-outline" class="mr-2 text-[var(--primary)]" />
{i18n(I18nKey.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>
</MainGridLayout>
<script>
// Copy button functionality
document.getElementById('copy-atom-btn')?.addEventListener('click', function() {
const url = this.getAttribute('data-url');
if (url) {
navigator.clipboard.writeText(url).then(() => {
const originalText = this.textContent || "Copy Link";
this.textContent = "Atom link copied to clipboard!";
setTimeout(() => {
this.textContent = originalText;
}, 2000);
}).catch(err => {
console.error('复制失败:', err);
const originalText = this.textContent || "Copy Link";
this.textContent = "Copy failed, please copy the link manually";
setTimeout(() => {
this.textContent = originalText;
}, 2000);
});
}
});
</script>

148
src/pages/atom.xml.ts Normal file
View File

@@ -0,0 +1,148 @@
import { getImage } from "astro:assets";
// import { getCollection } from "astro:content";
import type { APIContext, ImageMetadata } from "astro";
import MarkdownIt from "markdown-it";
import { parse as htmlParser } from "node-html-parser";
import sanitizeHtml from "sanitize-html";
import { profileConfig, siteConfig } from "@/config";
import { getSortedPosts } from "@/utils/content-utils";
import { getPostUrl } from "@/utils/url-utils";
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;
if (src.startsWith("./")) {
// Path relative to the post file directory
const prefixRemoved = src.slice(2);
// Check if this post is in a subdirectory (like bestimageapi/index.md)
const postPath = post.id; // This gives us the full path like "bestimageapi/index.md"
const postDir = postPath.includes("/") ? postPath.split("/")[0] : "";
if (postDir) {
// For posts in subdirectories
importPath = `/src/content/posts/${postDir}/${prefixRemoved}`;
} else {
// For posts directly in posts directory
importPath = `/src/content/posts/${prefixRemoved}`;
}
} else if (src.startsWith("../")) {
// Path like ../assets/images/xxx -> relative to /src/content/
const cleaned = src.replace(/^\.\.\//, "");
importPath = `/src/content/${cleaned}`;
} else {
// Handle direct filename (no ./ prefix) - assume it's in the same directory as the post
const postPath = post.id; // This gives us the full path like "bestimageapi/index.md"
const postDir = postPath.includes("/") ? postPath.split("/")[0] : "";
if (postDir) {
// For posts in subdirectories
importPath = `/src/content/posts/${postDir}/${src}`;
} else {
// For posts directly in posts directory
importPath = `/src/content/posts/${src}`;
}
}
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 {
// Debug: 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",
},
});
}

322
src/pages/devices.astro Normal file
View File

@@ -0,0 +1,322 @@
---
import { Icon } from "astro-icon/components";
import { siteConfig } from "../config";
import { devicesData } from "../data/devices";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
import MainGridLayout from "../layouts/MainGridLayout.astro";
export const prerender = true;
// 检查设备页面是否启用
if (!siteConfig.featurePages.devices) {
return Astro.redirect("/404/");
}
// 设备数据
const devices = devicesData;
const brands = Object.keys(devices);
---
<MainGridLayout title={i18n(I18nKey.devices)}>
<!-- 引入右侧边栏布局管理器 -->
<script>
import('../scripts/right-sidebar-layout.js');
</script>
<div class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full">
<!-- 页面标题 -->
<div class="flex flex-col items-start justify-center mb-8">
<h1 class="text-4xl font-bold text-black/90 dark:text-white/90 mb-2 relative
before:w-1 before:h-8 before:rounded-md before:bg-[var(--primary)]
before:absolute before:top-1/2 before:-translate-y-1/2 before:-left-4">
{i18n(I18nKey.devices)}
</h1>
<p class="text-lg text-black/60 dark:text-white/60">
{i18n(I18nKey.devicesSubtitle)}
</p>
</div>
<!-- 过滤按钮 -->
<div class="mb-6">
<div class="filter-container flex flex-wrap gap-2">
{brands.map((brand, index) => (
<button
data-brand={brand}
class={`filter-tag px-6 py-2.5 rounded-lg font-medium transition-all ${
index === 0 ? 'active' : ''
}`}
>
{brand}
</button>
))}
</div>
</div>
<!-- 设备列表 -->
<div id="devices-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{devices[brands[0]].map((device, index) => (
<a
href={device.link}
target="_blank"
rel="noopener noreferrer"
class="device-card group relative overflow-hidden rounded-xl border border-[var(--line-divider)] bg-[var(--card-bg)] transition-all duration-300 hover:border-[var(--primary)]/50 hover:shadow-md hover:shadow-black/5 dark:hover:shadow-white/5 hover:scale-[1.02] hover:-translate-y-0.5 block cursor-pointer"
style={`animation-delay: ${index * 100}ms`}
>
<!-- 设备图片区域 -->
<div class="relative p-6 pb-0">
<div class="flex justify-center items-center h-48 bg-gradient-to-br from-[var(--card-bg)] to-[var(--btn-regular-bg)] rounded-lg overflow-hidden relative">
<div class="absolute inset-0 bg-[var(--primary)]/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<img
src={device.image}
alt={device.name}
class="w-auto h-full max-h-full object-contain group-hover:scale-110 transition-all duration-500 drop-shadow-md relative z-10"
/>
</div>
</div>
<!-- 设备信息区域 -->
<div class="p-6 pt-4 relative z-10">
<div class="flex items-start justify-between mb-3">
<h3 class="text-lg font-bold text-black/90 dark:text-white/90 group-hover:text-[var(--primary)] transition-colors duration-300">
{device.name}
</h3>
<div class="p-1.5 rounded-full bg-[var(--primary)]/10 text-[var(--primary)] opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<Icon name="material-symbols:open-in-new" class="text-lg" />
</div>
</div>
<div class="mb-4">
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[var(--btn-regular-bg)] text-black/70 dark:text-white/70 text-sm mb-3">
<Icon name="material-symbols:settings-suggest-outline" class="text-sm" />
<span class="font-medium">{device.specs}</span>
</div>
<p class="text-sm text-black/60 dark:text-white/60 leading-relaxed line-clamp-2">
{device.description}
</p>
</div>
<!-- 查看详情按钮 -->
<div class="flex items-center justify-between pt-3 border-t border-[var(--line-divider)] border-dashed opacity-0 group-hover:opacity-100 transition-all duration-300">
<span class="text-sm font-medium text-[var(--primary)]">查看详情</span>
<Icon name="material-symbols:arrow-forward-rounded" class="text-lg text-[var(--primary)]" />
</div>
</div>
</a>
))}
</div>
</div>
</div>
<script is:inline define:vars={{ devices }}>
const brandTabs = document.querySelectorAll('.filter-tag');
const devicesContainer = document.getElementById('devices-container');
brandTabs.forEach(tab => {
tab.addEventListener('click', () => {
const brand = tab.dataset.brand;
// 移除所有激活状态
brandTabs.forEach(t => {
t.classList.remove('active');
});
// 激活当前点击的标签
tab.classList.add('active');
const brandDevices = devices[brand] || [];
devicesContainer.innerHTML = brandDevices.map((device, index) => `
<a
href="${device.link}"
target="_blank"
rel="noopener noreferrer"
class="device-card group relative overflow-hidden rounded-xl border border-[var(--line-divider)] bg-[var(--card-bg)] transition-all duration-300 hover:border-[var(--primary)]/50 hover:shadow-md hover:shadow-black/5 dark:hover:shadow-white/5 hover:scale-[1.02] hover:-translate-y-0.5 block cursor-pointer"
style="animation-delay: ${index * 100}ms; animation: fadeInUp 0.6s cubic-bezier(0.25, 0.1, 0.25, 1) forwards; opacity: 0;"
>
<!-- 设备图片区域 -->
<div class="relative p-6 pb-0">
<div class="flex justify-center items-center h-48 bg-gradient-to-br from-[var(--card-bg)] to-[var(--btn-regular-bg)] rounded-lg overflow-hidden relative">
<div class="absolute inset-0 bg-[var(--primary)]/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<img
src="${device.image}"
alt="${device.name}"
class="w-auto h-full max-h-full object-contain group-hover:scale-110 transition-all duration-500 drop-shadow-md relative z-10"
/>
</div>
</div>
<!-- 设备信息区域 -->
<div class="p-6 pt-4 relative z-10">
<div class="flex items-start justify-between mb-3">
<h3 class="text-lg font-bold text-black/90 dark:text-white/90 group-hover:text-[var(--primary)] transition-colors duration-300">
${device.name}
</h3>
<div class="p-1.5 rounded-full bg-[var(--primary)]/10 text-[var(--primary)] opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
</div>
<div class="mb-4">
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[var(--btn-regular-bg)] text-black/70 dark:text-white/70 text-sm mb-3">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span class="font-medium">${device.specs}</span>
</div>
<p class="text-sm text-black/60 dark:text-white/60 leading-relaxed line-clamp-2">
${device.description}
</p>
</div>
<!-- 查看详情按钮 -->
<div class="flex items-center justify-between pt-3 border-t border-[var(--line-divider)] border-dashed opacity-0 group-hover:opacity-100 transition-all duration-300">
<span class="text-sm font-medium text-[var(--primary)]">查看详情</span>
<svg class="w-5 h-5 text-[var(--primary)]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</div>
</div>
</a>
`).join('');
});
});
</script>
<style>
.filter-container {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
}
.filter-tag {
padding: 0.625rem 1.25rem;
border: 1px solid var(--line-divider);
border-radius: var(--radius-large);
background: var(--btn-regular-bg);
color: var(--btn-content);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
position: relative;
overflow: hidden;
}
.filter-tag::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--primary);
opacity: 0;
transition: opacity 0.3s ease;
z-index: -1;
}
.filter-tag:hover:not(.active) {
background: var(--btn-hover-bg);
border-color: var(--primary);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.filter-tag.active {
background: var(--primary);
color: white;
border-color: var(--primary);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(var(--color-primary-rgb), 0.3);
}
.filter-tag.active:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(var(--color-primary-rgb), 0.4);
}
/* 设备卡片动画效果 */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.onload-animation {
animation: fadeInUp 0.6s cubic-bezier(0.25, 0.1, 0.25, 1) forwards;
opacity: 0;
}
/* 卡片内容文本截断 */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 设备卡片微妙的悬停效果 */
.device-card::after {
content: '';
position: absolute;
inset: 0;
border-radius: 0.75rem;
background: linear-gradient(135deg, rgba(var(--color-primary-rgb), 0.05), transparent);
opacity: 0;
transition: opacity 0.4s ease;
pointer-events: none;
}
.device-card:hover::after {
opacity: 1;
}
/* 设备名称和规格的文本样式 */
.device-card h3 {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
/* 响应式调整 */
@media (max-width: 640px) {
.filter-container {
gap: 0.5rem;
}
.filter-tag {
padding: 0.5rem 1rem;
font-size: 0.8rem;
}
.device-card .relative.p-6.pb-0 {
padding: 1rem 1rem 0;
}
.device-card .p-6.pt-4 {
padding: 1rem;
}
}
/* 在深色模式下微妙的阴影效果 */
@media (prefers-color-scheme: dark) {
.device-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
</style>
</MainGridLayout>

267
src/pages/diary.astro Normal file
View File

@@ -0,0 +1,267 @@
---
import { siteConfig } from "../config";
import { getDiaryList, getDiaryStats } from "../data/diary";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
import MainGridLayout from "../layouts/MainGridLayout.astro";
// 检查日记页面是否启用
if (!siteConfig.featurePages.diary) {
return Astro.redirect("/404/");
}
// 获取日记数据
const moments = getDiaryList();
const diaryStats = getDiaryStats();
// 时间格式化函数
function formatTime(dateString: string): string {
var TG = 8;
if (siteConfig.timeZone >= -12 && siteConfig.timeZone <= 12)
TG = siteConfig.timeZone;
const timeGap = TG;
// const lang = siteConfig.lang;
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)}`;
}
---
<MainGridLayout title={i18n(I18nKey.diary)} description="即刻短文">
<!-- 引入右侧边栏布局管理器 -->
<script>
import('../scripts/right-sidebar-layout.js');
</script>
<div class="flex w-full rounded-[var(--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">
<div class="relative max-w-4xl w-full px-2 md:px-0">
<!-- 页面头部 -->
<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">{i18n(I18nKey.diary)}</h1>
<p class="moments-subtitle text-sm md:text-base lg:text-lg text-75">{i18n(I18nKey.diarySubtitle)}</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-[var(--primary)]">{diaryStats.total}</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">
{moments.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-transform">
<img
src={image}
alt="diary moment image"
class="w-full h-full object-cover"
loading="lazy"
/>
</div>
))}
</div>
)}
</div>
<hr class="moment-divider border-t border-[var(--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>
</MainGridLayout>
<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: transform 0.3s ease;
}
.image-item:hover img {
transform: scale(1.05);
}
.action-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
}
.action-btn:hover {
color: var(--primary);
}
/* 响应式设计 */
/* 手机端 (小于640px) */
@media (max-width: 640px) {
.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;
}
}
/* 平板竖屏 (641px - 900px) - 优化显示 */
@media (min-width: 641px) and (max-width: 900px) {
.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;
}
}
/* 平板横屏和桌面端 (大于900px) */
@media (min-width: 901px) {
.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;
}
}
/* 优化小屏幕显示 */
@media (max-width: 480px) {
.moment-item {
border-radius: 8px;
}
.moments-header {
border-radius: 6px;
}
}
</style>

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

@@ -0,0 +1,290 @@
---
import { getEntry, render } from "astro:content";
import Markdown from "@components/misc/Markdown.astro";
// import Icon from "../components/misc/Icon.astro";
import { siteConfig } from "../config";
import { getShuffledFriendsList } from "../data/friends";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
import MainGridLayout from "../layouts/MainGridLayout.astro";
// 检查友链页面是否启用
if (!siteConfig.featurePages.friends) {
return Astro.redirect("/404/");
}
const friendsPost = await getEntry("spec", "friends");
if (!friendsPost) {
throw new Error("friends page content not found");
}
const { Content } = await render(friendsPost);
const friendsList = getShuffledFriendsList();
// 获取所有标签
const allTags = Array.from(new Set(friendsList.flatMap((item) => item.tags)));
---
<MainGridLayout title={i18n(I18nKey.friends)} description={i18n(I18nKey.friends)}>
<!-- 只在友情链接页面加载脚本 -->
<script is:inline>
// 标记当前页面为友情链接页面
window.isFriendsPage = true;
</script>
<script>
import('../scripts/right-sidebar-layout.js');
</script>
<div class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full">
<!-- 页面标题 -->
<div class="flex flex-col items-start justify-center mb-8">
<h1 class="text-4xl font-bold text-black/90 dark:text-white/90 mb-2 relative
before:w-1 before:h-8 before:rounded-md before:bg-[var(--primary)]
before:absolute before:top-1/2 before:-translate-y-1/2 before:-left-4">
{i18n(I18nKey.friends)}
</h1>
<p class="text-lg text-black/60 dark:text-white/60">
{i18n(I18nKey.friendsSubtitle)}
</p>
</div>
<!-- 搜索和筛选栏 -->
<div class="mb-6 space-y-3">
<!-- 搜索框 -->
<div class="w-full">
<div class="relative">
<input
type="text"
id="friend-search"
placeholder={i18n(I18nKey.friendsSearchPlaceholder)}
class="w-full px-4 py-2 pl-10 rounded-lg bg-[var(--btn-regular-bg)]
text-black/90 dark:text-white/90
border border-black/10 dark:border-white/10
focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50
transition-all duration-200"
/>
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-black/40 dark:text-white/40"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
</div>
<!-- 标签筛选 -->
<div class="filter-container flex flex-wrap gap-2">
<button
class="filter-tag active"
data-tag="all"
>
{i18n(I18nKey.friendsFilterAll)}
</button>
{allTags.map(tag => (
<button
class="filter-tag"
data-tag={tag}
>
{tag}
</button>
))}
</div>
</div>
<!-- 友链卡片网格 -->
<div id="friends-grid" class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-6 mb-6">
{friendsList.map((item) => (
<div
class="friend-card group relative bg-transparent rounded-xl border border-black/10 dark:border-white/10
overflow-hidden transition-all duration-300
hover:shadow-xl hover:-translate-y-1"
data-title={item.title.toLowerCase()}
data-desc={item.desc.toLowerCase()}
data-tags={item.tags.join(',')}
>
<!-- 卡片内容 -->
<div class="p-6">
<!-- 头像和标题区 -->
<div class="flex items-start gap-4 mb-4">
<!-- 网站图标 -->
<div class="w-16 h-16 flex-shrink-0 rounded-xl overflow-hidden
bg-[var(--btn-regular-bg)]
ring-2 ring-transparent
transition-all duration-300">
<img
src={item.imgurl}
alt={item.title}
class="w-full h-full object-cover transform group-hover:scale-110 transition-transform duration-300"
loading="lazy"
/>
</div>
<!-- 标题和链接 -->
<div class="flex-1 min-w-0">
<h3 class="text-xl font-bold text-black/90 dark:text-white/90 mb-1 truncate
group-hover:text-[var(--primary)] transition-colors duration-200">
{item.title}
</h3>
<a
href={item.siteurl}
target="_blank"
rel="noopener noreferrer"
class="text-xs text-black/50 dark:text-white/50 hover:text-[var(--primary)]
truncate block transition-colors duration-200"
>
{new URL(item.siteurl).hostname}
</a>
</div>
</div>
<!-- 描述 -->
<p class="text-sm text-black/60 dark:text-white/60 mb-4 line-clamp-2 min-h-[2.5rem]">
{item.desc}
</p>
<!-- 标签 -->
<div class="flex flex-wrap gap-2 mb-4">
{item.tags.map(tag => (
<span class="px-2 py-1 text-xs rounded-md
bg-[var(--primary)]/10 text-[var(--primary)]
font-medium">
{tag}
</span>
))}
</div>
<!-- 操作按钮 -->
<div class="flex gap-2">
<a
href={item.siteurl}
target="_blank"
rel="noopener noreferrer"
class="flex-1 flex items-center justify-center gap-2 px-4 py-2
rounded-lg bg-[var(--primary)] text-white
hover:bg-[var(--primary)]/90
active:scale-95 transition-all duration-200
font-medium text-sm"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
{i18n(I18nKey.friendsVisit)}
</a>
<button
class="copy-link-btn px-3 py-2 rounded-lg
bg-[var(--btn-regular-bg)]
hover:bg-[var(--btn-regular-bg-hover)]
active:scale-95 transition-all duration-200
text-black/70 dark:text-white/70"
data-url={item.siteurl}
title={i18n(I18nKey.friendsCopyLink)}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</button>
</div>
</div>
<!-- 悬停装饰效果 -->
<div class="absolute inset-0 bg-gradient-to-br from-[var(--primary)]/5 to-transparent
opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none">
</div>
</div>
))}
</div>
<!-- 无结果提示 -->
<div id="no-results" class="hidden text-center py-12">
<svg class="w-16 h-16 mx-auto mb-4 text-black/20 dark:text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p class="text-black/50 dark:text-white/50 text-lg">{i18n(I18nKey.friendsNoResults)}</p>
</div>
<!-- 说明文档 -->
<Markdown class="mt-8 prose dark:prose-invert max-w-none">
<Content />
</Markdown>
<!-- 隐藏元素用于传递 i18n 文本到全局脚本 -->
<div id="friends-copy-success-text" style="display: none;">{i18n(I18nKey.friendsCopySuccess)}</div>
</div>
</div>
<!-- 引用全局脚本 -->
<script is:inline src="/js/friends-page-handler.js"></script>
</MainGridLayout>
<style>
/* 友链卡片动画 */
.friend-card {
animation: fadeInUp 0.5s ease-out forwards;
opacity: 0;
}
.friend-card:nth-child(1) { animation-delay: 0.05s; }
.friend-card:nth-child(2) { animation-delay: 0.1s; }
.friend-card:nth-child(3) { animation-delay: 0.15s; }
.friend-card:nth-child(4) { animation-delay: 0.2s; }
.friend-card:nth-child(5) { animation-delay: 0.25s; }
.friend-card:nth-child(6) { animation-delay: 0.3s; }
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 搜索框焦点效果 */
#friend-search:focus {
box-shadow: 0 0 0 3px var(--primary) / 0.1;
}
/* 标签筛选按钮样式 - 与番剧页面一致 */
.filter-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.filter-tag {
padding: 0.5rem 1rem;
border: 1px solid var(--line-divider);
border-radius: var(--radius-large);
background: var(--btn-regular-bg);
color: var(--btn-content);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.filter-tag:hover:not(.active) {
background: var(--btn-hover-bg);
border-color: var(--primary);
transform: translateY(-1px);
}
.filter-tag.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.filter-tag.active:hover {
background: var(--primary) !important;
color: white !important;
border-color: var(--primary) !important;
transform: translateY(-1px);
}
</style>

View File

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

View File

@@ -0,0 +1,404 @@
---
import type { CollectionEntry } from "astro:content";
import { render } from "astro:content";
// import path from "node:path";
import Comment from "@components/comment/index.astro";
import License from "@components/misc/License.astro";
import Markdown from "@components/misc/Markdown.astro";
import PasswordProtection from "@components/PasswordProtection.astro";
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import MainGridLayout from "@layouts/MainGridLayout.astro";
import { getSortedPosts } from "@utils/content-utils";
import {
// getDir,
getFileDirFromPath,
getPostUrlBySlug,
removeFileExtension,
} from "@utils/url-utils";
import { Icon } from "astro-icon/components";
import bcryptjs from "bcryptjs";
import CryptoJS from "crypto-js";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { licenseConfig } from "src/config";
import ImageWrapper from "../../components/misc/ImageWrapper.astro";
import PostMetadata from "../../components/PostMeta.astro";
import { profileConfig, siteConfig } from "../../config";
import { formatDateToYYYYMMDD } from "../../utils/date-utils";
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.permalink) {
// 移除开头的斜杠和结尾的斜杠
// 同时移除可能的 "posts/" 前缀,避免重复
let permalink = entry.data.permalink
.replace(/^\/+/, "")
.replace(/\/+$/, "");
if (permalink.startsWith("posts/")) {
permalink = permalink.replace(/^posts\//, "");
}
paths.push({
params: { slug: permalink },
props: { entry },
});
}
}
return paths;
}
const { entry } = Astro.props;
const { Content, headings } = await render(entry);
const { remarkPluginFrontmatter } = await render(entry);
// 处理加密逻辑
let encryptedContent = "";
let passwordHash = "";
let isEncrypted = entry.data.encrypted && entry.data.password;
if (isEncrypted) {
// 对于加密文章,我们将使用内容进行加密
// 在客户端解密后会通过JavaScript重新渲染
const contentToEncrypt = entry.body;
// 生成密码哈希使用环境变量配置盐值轮数默认12
const saltRounds = Number.parseInt(
import.meta.env.BCRYPT_SALT_ROUNDS || "12",
10,
);
passwordHash = bcryptjs.hashSync(entry.data.password, saltRounds);
// 使用密码哈希的一部分作为加密密钥,而不是明文密码
// 这样即使攻击者获取到加密内容,也无法直接用原始密码解密
const encryptionKey = passwordHash.substring(0, 32); // 使用哈希的前32个字符作为密钥
// 使用派生密钥加密内容
encryptedContent = CryptoJS.AES.encrypt(
contentToEncrypt,
encryptionKey,
).toString();
}
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
};
---
<MainGridLayout
banner={entry.data.image}
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)} />
<!-- 引入右侧边栏布局管理器 -->
<script>
import('../../scripts/right-sidebar-layout.js');
</script>
<div
class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative mb-4"
>
<div
id="post-container"
class:list={[
"card-base z-10 px-6 md:px-9 pt-6 pb-4 relative w-full ",
{},
]}
>
<!-- word count and reading time -->
<div
class="flex flex-row text-black/30 dark:text-white/30 gap-5 mb-3 transition onload-animation"
>
<div class="flex flex-row items-center">
<div
class="transition h-6 w-6 rounded-md bg-black/5 dark:bg-white/10 text-black/50 dark:text-white/50 flex items-center justify-center mr-2"
>
<Icon name="material-symbols:notes-rounded" />
</div>
<div class="text-sm">
{remarkPluginFrontmatter.words}
{" " + i18n(I18nKey.wordsCount)}
</div>
</div>
<div class="flex flex-row items-center">
<div
class="transition h-6 w-6 rounded-md bg-black/5 dark:bg-white/10 text-black/50 dark:text-white/50 flex items-center justify-center mr-2"
>
<Icon
name="material-symbols:schedule-outline-rounded"
/>
</div>
<div class="text-sm">
{remarkPluginFrontmatter.minutes}
{
" " +
i18n(
remarkPluginFrontmatter.minutes === 1
? I18nKey.minuteCount
: I18nKey.minutesCount,
)
}
</div>
</div>
</div>
<!-- title -->
<div class="relative onload-animation">
<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-[var(--primary)]
before:absolute before:top-[0.75rem] before:left-[-1.125rem]"
>
{entry.data.title}
</div>
</div>
<!-- metadata -->
<div class="onload-animation">
<PostMetadata
className="mb-5"
published={entry.data.published}
updated={entry.data.updated}
tags={entry.data.tags}
category={entry.data.category || undefined}
id={entry.id}
/>
{
!entry.data.image && (
<div class="mt-4 border-[var(--line-divider)] border-dashed border-b-[1px] mb-5" />
)
}
</div>
<!-- always show cover as long as it has one -->
{
entry.data.image && (
<>
<div class="mt-4" />
<ImageWrapper
id="post-cover"
src={entry.data.image}
basePath={getFileDirFromPath(entry.filePath || '')}
class="mb-8 rounded-xl banner-container onload-animation"
/>
</>
)
}
{
isEncrypted ? (
<PasswordProtection
encryptedContent={encryptedContent}
passwordHash={passwordHash}
/>
) : (
<>
<Markdown class="mb-6 markdown-content onload-animation">
<Content />
</Markdown>
{licenseConfig.enable && (
<License
title={entry.data.title}
id={entry.id}
pubDate={entry.data.published}
author={entry.data.author}
sourceLink={entry.data.sourceLink}
licenseName={entry.data.licenseName}
licenseUrl={entry.data.licenseUrl}
class="mb-6 rounded-xl license-container onload-animation"
/>
)}
</>
)
}
</div>
</div>
<!-- 评论 -->
<Comment post={entry} />
{siteConfig.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');
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="flex items-center gap-3">
<div class="flex items-center justify-center h-12 w-12 rounded-lg bg-[var(--enter-btn-bg)]">
<Icon
name="material-symbols:history-rounded"
class="text-3xl text-[var(--primary)]"
/>
</div>
<div class="flex flex-col gap-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
class="flex flex-col md:flex-row justify-between mb-4 gap-4 overflow-hidden w-full"
>
<a
href={entry.data.nextSlug
? getPostUrlBySlug(entry.data.nextSlug)
: "#"}
class:list={[
"w-full font-bold overflow-hidden active:scale-95",
{ "pointer-events-none": !entry.data.nextSlug },
]}
>
{
entry.data.nextSlug && (
<div class="btn-card rounded-2xl w-full h-[3.75rem] max-w-full px-4 flex items-center !justify-start gap-4">
<Icon
name="material-symbols:chevron-left-rounded"
class="text-[2rem] text-[var(--primary)]"
/>
<div class="overflow-hidden transition overflow-ellipsis whitespace-nowrap max-w-[calc(100%_-_3rem)] text-black/75 dark:text-white/75">
{entry.data.nextTitle}
</div>
</div>
)
}
</a>
<a
href={entry.data.prevSlug
? getPostUrlBySlug(entry.data.prevSlug)
: "#"}
class:list={[
"w-full font-bold overflow-hidden active:scale-95",
{ "pointer-events-none": !entry.data.prevSlug },
]}
>
{
entry.data.prevSlug && (
<div class="btn-card rounded-2xl w-full h-[3.75rem] max-w-full px-4 flex items-center !justify-end gap-4">
<div class="overflow-hidden transition overflow-ellipsis whitespace-nowrap max-w-[calc(100%_-_3rem)] text-black/75 dark:text-white/75">
{entry.data.prevTitle}
</div>
<Icon
name="material-symbols:chevron-right-rounded"
class="text-[2rem] text-[var(--primary)]"
/>
</div>
)
}
</a>
</div>
</MainGridLayout>

Some files were not shown because too many files have changed in this diff Show More