111
1
src/FooterConfig.html
Normal file
@@ -0,0 +1 @@
|
||||
这里是HTML注入示例,你可以在这个文件中添加自定义的HTML内容
|
||||
BIN
src/assets/images/avatar.webp
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
src/assets/images/my_own_photo.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
167
src/components/ArchivePanel.svelte
Normal 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>
|
||||
24
src/components/ConfigCarrier.astro
Normal 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>
|
||||
248
src/components/CustomScrollbar.astro
Normal 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>
|
||||
54
src/components/Footer.astro
Normal 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(--primary)] mx-16 border-dashed py-8 max-w-[var(--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(--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>
|
||||
)}
|
||||
© <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> 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>
|
||||
3
src/components/GlobalStyles.astro
Normal file
@@ -0,0 +1,3 @@
|
||||
---
|
||||
|
||||
---
|
||||
207
src/components/LayoutSwitchButton.svelte
Normal 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>
|
||||
95
src/components/LightDarkSwitch.svelte
Normal 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>
|
||||
651
src/components/MobileTOC.svelte
Normal 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
@@ -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>}
|
||||
636
src/components/PasswordProtection.astro
Normal 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>
|
||||
127
src/components/PostCard.astro
Normal 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>
|
||||
171
src/components/PostMeta.astro
Normal 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>
|
||||
)}
|
||||
370
src/components/PostPage.astro
Normal 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>
|
||||
211
src/components/Search.svelte
Normal 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>
|
||||
152
src/components/TypewriterText.astro
Normal 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 = ' '; // 使用不间断空格保持布局
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
96
src/components/WallpaperSwitch.svelte
Normal 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>
|
||||
82
src/components/comment/Twikoo.astro
Normal 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>
|
||||
27
src/components/comment/index.astro
Normal 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>
|
||||
)}
|
||||
67
src/components/control/BackToTop.astro
Normal 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>
|
||||
43
src/components/control/ButtonLink.astro
Normal 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>
|
||||
13
src/components/control/ButtonTag.astro
Normal 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>
|
||||
84
src/components/control/Pagination.astro
Normal 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>
|
||||
172
src/components/layout/RightSideBar.astro
Normal 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>
|
||||
102
src/components/misc/AnimationTest.astro
Normal 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>
|
||||
236
src/components/misc/FullscreenWallpaper.astro
Normal 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>
|
||||
)}
|
||||
181
src/components/misc/Icon.astro
Normal 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>
|
||||
272
src/components/misc/IconifyLoader.astro
Normal 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>
|
||||
55
src/components/misc/ImageWrapper.astro
Normal 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>
|
||||
48
src/components/misc/License.astro
Normal 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>
|
||||
136
src/components/misc/Markdown.astro
Normal 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>
|
||||
324
src/components/skills/SkillsChart.astro
Normal 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样式 -->
|
||||
82
src/components/widget/Announcement.astro
Normal 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>
|
||||
282
src/components/widget/Calendar.astro
Normal 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>
|
||||
41
src/components/widget/Categories.astro
Normal 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>
|
||||
93
src/components/widget/DisplaySettings.svelte
Normal 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>
|
||||
231
src/components/widget/DropdownMenu.astro
Normal 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>
|
||||
934
src/components/widget/MusicPlayer.svelte
Normal 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}
|
||||
164
src/components/widget/NavMenuPanel.astro
Normal 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>
|
||||
109
src/components/widget/Pio.svelte
Normal 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>
|
||||
108
src/components/widget/Profile.astro
Normal 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>
|
||||
)}
|
||||
216
src/components/widget/ProjectCard.astro
Normal 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>
|
||||
101
src/components/widget/RightSideBar.astro
Normal 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>
|
||||
166
src/components/widget/SideBar.astro
Normal 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>
|
||||
163
src/components/widget/SiteStats.astro
Normal 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>
|
||||
230
src/components/widget/SkillCard.astro
Normal 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>
|
||||
177
src/components/widget/StatCard.astro
Normal 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>
|
||||
366
src/components/widget/TOC.astro
Normal 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>
|
||||
38
src/components/widget/Tags.astro
Normal 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>
|
||||
384
src/components/widget/TimelineItem.astro
Normal 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>
|
||||
63
src/components/widget/WidgetLayout.astro
Normal 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
@@ -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-6,1 表示只显示 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;
|
||||
24
src/constants/constants.ts
Normal 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
@@ -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",
|
||||
},
|
||||
];
|
||||
56
src/constants/link-presets.ts
Normal 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
@@ -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
@@ -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,
|
||||
};
|
||||
48
src/content/posts/readnote/freedom.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: 《圆圈正义》读后感:永不完美的道德理想与坚持热望
|
||||
published: 2025-12-07
|
||||
description: '阅读《圆圈正义》有感而发'
|
||||
image: './freedom.png'
|
||||
tags: [BOOK, 书评]
|
||||
category: '读书笔记'
|
||||
draft: false
|
||||
lang: 'zh_CN'
|
||||
---
|
||||
|
||||
>“我们能画出的圆圈总是不够圆,但没有人会因此想取消圆圈。”——《圆圈正义》
|
||||
|
||||
***《圆圈正义》***是罗翔老师一部深刻探讨法律、道德与正义的随笔集。他以一位刑法学教授兼公共知识分子的身份,既剖析社会现象,亦直面人心幽暗之处。全书处处闪耀着思辨光芒与真挚的人性关怀,尤其那核心隐喻“圆圈正义”,已成为理解现代性道德困境的重要意象。
|
||||
|
||||
# ***不完美的圆圈:理想与现实的永恒张力***
|
||||
罗翔提出的 ***圆圈正义*** 堪称精妙——正义如同数学中“圆”的概念一样,客观存在却无法在现实中被完美绘制。正如法律永远只能趋近于绝对正义却无法全然达成,人性永远在神圣与幽暗之间挣扎。
|
||||
|
||||
这个隐喻警醒我们:*正义有其客观向度(如不杀人、尊重生命是普遍的道德直觉)*,却又在具体执行中充满复杂性与妥协。认识到这种 ***圆而不圆*** 的张力,恰是走出偏狭的道德自恋的第一步。
|
||||
|
||||
在阅读过程中我常常自省:我们常常以 **没有人能做到完美** 而放弃追求,又或因理想看似不可能而沮丧怠惰。
|
||||
|
||||
罗翔则指出:正因为圆不可画出完美的实体, **努力接近理想状态** 才拥有了道德意义。那些 **“虽不能至,心向往之”** 的坚持,恰是人性最珍贵的光芒。
|
||||
|
||||
# ***法律与人性的双重镜鉴***
|
||||
书中最为深刻之处在于它同时照亮了法律与人性的双重维度:
|
||||
- **法律的局限与勇气**:罗翔并不迷信法律万能,直言其难免带有权力的烙印与时代限制。然法律的公正实施,是阻止“人祸”的底线屏障。书中对刑法的解说并非仅灌输法条,而重在揭示刑罚背后的价值冲突与人道精神。
|
||||
- **人性的复杂透视**:他拒绝简单的“性善论”或“性恶论”,而是坦诚直面人性中存在的黑暗冲动(如嫉妒、自私)与崇高可能(如同理心、良心召唤)。我们每个人都可能身处“强人”或“弱者”的位置,道德选择从来与角色无关。
|
||||
|
||||
## ***思想共振***
|
||||
|
||||
>道德不是简单地追求尽善尽美,而是要求我们尽量避免成为他人苦难的助力。
|
||||
***——面对无法阻止的恶,至少保持沉默本身就是一种微弱的抵抗。***
|
||||
|
||||
>愤怒本身何尝不是一种礼物,它提醒我们内心尚未麻木。
|
||||
***——关键在于愤怒之后:是滑向仇恨的深渊?还是反思、对话与行动的起点?***
|
||||
|
||||
>法律只针对人类有限的行为予以规制,其目的并非制造完人,而是阻止最坏的灾难发生。
|
||||
***——拒绝将法律置于道德制高点,也拒绝放弃法律作为文明的最后堤坝。***
|
||||
|
||||
# ***在局限中仍举灯行走***
|
||||
在当下社会思潮纷乱、公共讨论常流于偏颇撕裂的语境中,《圆圈正义》如同一盏温暖而清醒的灯火。它在提醒我们:真正的道德生活,不是幻想能一劳永逸画出一个完美的圆来宣告理想已实现,而是日复一日地拿起笔来,在现实的泥泞土地上,带着谦卑、审慎却又无比固执地,画下去。
|
||||
|
||||
圆圈难圆,然其理想不陨。正义如星辰,虽不可及,却足以为在黑夜中跋涉者导航。这或许是罗翔老师留给这个喧嚣时代最宝贵的精神馈赠。
|
||||
|
||||
:::note[总结]
|
||||
看完不算舒服,有种被打碎又重组后更结实的感觉。强推给所有对生活、对社会、对自己还有点“困惑”和“不平”的人。
|
||||
:::
|
||||
BIN
src/content/posts/readnote/freedom.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
48
src/content/posts/vasp/VASP.md
Normal 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简介
|
||||
|
||||
VASP(Vienna 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
|
After Width: | Height: | Size: 44 KiB |
BIN
src/content/posts/vps/2.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src/content/posts/vps/3.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
118
src/content/posts/vps/VPS.md
Normal 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 | 50GB(SSD缓存) | 1TB/月 @1Gbps | 洛杉矶 | $9.99/年 |
|
||||
| CM-25-SSD-VPS-2 | 3 cores | 2GB | 30GB(SSD) | 4TB/月 @1Gbps | 洛杉矶、圣路易斯、雷斯顿 | $16.99/年起 |
|
||||
| CM-25-SSD-VPS-3| 6 cores | 4GB | 60GB(SSD) | 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`(然后等待部署)
|
||||
|
||||
>主页中如图显示即为完成
|
||||

|
||||
|
||||
# 连接服务器
|
||||
## 获取服务器信息
|
||||
* 打开[CloudCone后台](https://www.cloudcone.com),重新设置`root`账户密码
|
||||
查看服务器IP地址(需要注意的是,IPV6地址需要在后台手动申请)
|
||||
* 打开任意一款ssh工具,这里用Xshell演示
|
||||
>如下填写
|
||||
>
|
||||
|
||||
>
|
||||
|
||||
配置完成点击连接,弹出窗口选保存密钥即可。
|
||||
|
||||
## 配置基础环境
|
||||
* 更新系统软件包
|
||||
```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)
|
||||
|
||||
测试ip:148.135.114.94
|
||||
|
||||
测速页:https://lg-la.us.cloudc.one/
|
||||
|
||||
美国 圣路易斯(St. Louis, MO)
|
||||
|
||||
测试ip:66.154.118.2
|
||||
|
||||
测速页:https://lg-stl.us.cloudc.one/
|
||||
|
||||
美国 雷斯顿(Reston, VA)
|
||||
|
||||
测试ip:66.154.126.2
|
||||
|
||||
测速页:https://lg-rstn.us.cloudc.one/
|
||||
BIN
src/content/posts/vps/cover.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
51
src/content/spec/about.md
Normal 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)
|
||||
* **目标:** 提供背景上下文,清晰定义个人发展路径与现状状态。
|
||||
0
src/content/spec/friends.md
Normal file
55
src/data/anime.ts
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]:
|
||||
"RSS(Really 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
@@ -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]:
|
||||
"RSS(Really 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
@@ -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]:
|
||||
"RSS(Really 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
@@ -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
1611
src/layouts/MainGridLayout.astro
Normal file
77
src/pages/404.astro
Normal 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
@@ -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
@@ -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
@@ -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>
|
||||
183
src/pages/albums/[id]/index.astro
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
344
src/pages/og/[...slug].png.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
404
src/pages/posts/[...slug].astro
Normal 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>
|
||||