Initial commit

This commit is contained in:
2026-02-02 22:47:52 +03:00
committed by GitHub
commit f53016aeda
239 changed files with 84360 additions and 0 deletions

View File

@@ -0,0 +1,157 @@
<script lang="ts">
import { onMount } from "svelte";
import { getPostUrl } from "@utils/url";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
interface Post {
id: string;
data: {
title: string;
tags: string[];
category?: string | null;
published: Date | string;
routeName?: string;
};
}
interface Group {
year: number;
posts: Post[];
}
interface Props {
sortedPosts?: Post[];
}
let { sortedPosts = [] }: Props = $props();
let tags = $state<string[]>([]);
let categories = $state<string[]>([]);
let uncategorized = $state<string | null>(null);
onMount(() => {
const params = new URLSearchParams(window.location.search);
tags = params.has("tag") ? params.getAll("tag") : [];
categories = params.has("category") ? params.getAll("category") : [];
uncategorized = params.get("uncategorized");
});
function formatDate(date: Date | string) {
const d = new Date(date);
const month = (d.getMonth() + 1).toString().padStart(2, "0");
const day = d.getDate().toString().padStart(2, "0");
return `${month}-${day}`;
}
function formatTag(tagList: string[]) {
return tagList.map((t) => `#${t}`).join(" ");
}
let groups = $derived.by(() => {
let filteredPosts = sortedPosts.map((post) => ({
...post,
data: {
...post.data,
published: new Date(post.data.published),
},
}));
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 !== null) {
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);
return groupedPostsArray;
});
</script>
<div>
{#each groups as group}
<div>
<div class="flex flex-row w-full items-center h-15">
<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-solid outline-(--primary) mx-auto outline-offset-2 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-(--primary)
outline-4 z-50
outline-(--card-bg)
group-hover:outline-(--btn-plain-bg-hover)
group-active:outline-(--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-(--primary)
text-75 pr-8 whitespace-nowrap text-ellipsis overflow-hidden"
>
{post.data.title}
</div>
<!-- tag list -->
<div class="hidden md:block md:w-[15%] text-left text-sm transition whitespace-nowrap text-ellipsis overflow-hidden text-30"
>
{formatTag(post.data.tags)}
</div>
</div>
</a>
{/each}
</div>
{/each}
</div>

View File

@@ -0,0 +1,75 @@
---
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 z-100 hide flex items-center rounded-2xl overflow-hidden transition"
onclick="backToTop()">
<button aria-label="Back to Top" class="btn-card h-full w-full">
<Icon name="material-symbols:keyboard-arrow-up-rounded" class="mx-auto"></Icon>
</button>
</div>
</div>
<style lang="stylus">
.back-to-top-wrapper
width: 3rem
height: 3rem
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: 6.78rem
opacity: 1
right: 1rem
cursor: pointer
translate: 0 0
pointer-events: auto
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
box-shadow:
0 0 0 1px var(--btn-regular-bg),
0 0 1em var(--btn-regular-bg);
button
width: 3rem
height: 3rem
i
font-size: 1.75rem
&.hide
translate: 0 2rem
scale: 0.9
opacity: 0
pointer-events: none
&:active
scale: 0.9
// Mobile
@media (max-width: 768px)
.back-to-top-btn
bottom: 5.5rem
right: 0.5rem
font-size: 2rem
&.hide
translate: 0 2rem
scale: 0.9
&:active
scale: 0.9
</style>
<script is:raw is:inline>
function backToTop() {
// 直接使用原生滚动避免OverlayScrollbars冲突
window.scroll({ top: 0, behavior: 'smooth' });
}
</script>

View File

@@ -0,0 +1,45 @@
---
import { getParentLink } from "@utils/navigation";
import { url } from "@utils/url";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
interface Props {
currentPath?: string;
href?: string;
text?: string;
}
const { currentPath, href, text } = Astro.props;
let targetUrl = href;
let targetText = text;
if (currentPath && !targetUrl) {
const parentLink = getParentLink(currentPath);
if (parentLink) {
targetUrl = url(parentLink.url);
targetText = `${i18n(I18nKey.backTo)} ${parentLink.name}`;
}
}
// 如果既没有传入 href也没能根据 currentPath 找到父级菜单,则不渲染
if (!targetUrl) {
return null;
}
---
<div class="mb-6">
<a
href={targetUrl}
class="inline-flex items-center gap-2 text-neutral-600 dark:text-neutral-400 hover:text-(--primary) transition-colors group"
>
<svg class="w-4 h-4 transition-transform group-hover:-translate-x-1" 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>
<span class="text-sm font-medium">
{targetText}
</span>
</a>
</div>

364
src/components/banner.astro Normal file
View File

@@ -0,0 +1,364 @@
---
import type { SiteConfig } from "@/types/config";
import { BANNER_HEIGHT_EXTEND } from "@constants/constants";
import TypewriterText from "@components/common/typewriterText.astro";
import ImageWrapper from "@components/common/imageWrapper.astro";
interface Props {
config: SiteConfig["wallpaper"];
isHomePage: boolean;
class?: string;
}
const { config, isHomePage, class: className } = Astro.props;
// 获取当前设备类型的图片源
const getImageSources = () => {
const toArray = (src: any) => [src || []].flat();
const { src } = config;
const isObj = src && typeof src === "object" && !Array.isArray(src);
const desktop = toArray(isObj ? (src as any).desktop : src);
const mobile = toArray(isObj ? (src as any).mobile : src);
return {
desktop: desktop.length > 0 ? desktop : mobile,
mobile: mobile.length > 0 ? mobile : desktop,
};
}
const imageSources = getImageSources();
// 轮播配置
const carouselConfig = config.carousel;
const isCarouselEnabled = imageSources.desktop.length > 1 || imageSources.mobile.length > 1;
const carouselInterval = carouselConfig?.interval || 6
// 样式配置
const showHomeText = config.banner?.homeText?.enable && isHomePage;
const showWaves = config.banner?.waves?.enable;
const isPerformanceMode = config.banner?.waves?.performanceMode;
---
<!-- Banner Wrapper -->
<div
id="banner-wrapper"
class:list={[
"absolute z-10 w-full transition-all duration-600 overflow-hidden",
className
]}
style={`top: -${BANNER_HEIGHT_EXTEND}vh`}
>
{isCarouselEnabled ? (
<div id="banner-carousel" class="relative h-full w-full" data-carousel-config={JSON.stringify(carouselConfig)}>
<ul class="carousel-list h-full w-full">
{imageSources.desktop.map((src, index) => (
<li class:list={[
"carousel-item desktop-item hidden md:block absolute inset-0 transition-opacity duration-1200",
index === 0 ? 'opacity-100' : 'opacity-0',
carouselConfig?.kenBurns !== false ? 'ken-burns-enabled' : ''
]}>
<ImageWrapper
alt={`Desktop banner ${index + 1}`}
class:list={["object-cover h-full w-full"]}
src={src}
position={config.position}
loading={index === 0 ? "eager" : "lazy"}
/>
</li>
))}
{imageSources.mobile.map((src, index) => (
<li class:list={[
"carousel-item mobile-item block md:hidden absolute inset-0 transition-opacity duration-1200",
index === 0 ? 'opacity-100' : 'opacity-0',
carouselConfig?.kenBurns !== false ? 'ken-burns-enabled' : ''
]}>
<ImageWrapper
alt={`Mobile banner ${index + 1}`}
class:list={["object-cover h-full w-full"]}
src={src}
position={config.position}
loading={index === 0 ? "eager" : "lazy"}
/>
</li>
))}
</ul>
</div>
) : (
<div class="relative h-full w-full">
<ImageWrapper
alt="Mobile banner"
class:list={["block md:hidden object-cover h-full w-full transition duration-600 opacity-100"]}
src={imageSources.mobile[0] || imageSources.desktop[0] || ''}
position={config.position}
loading="eager"
/>
<ImageWrapper
id="banner"
alt="Desktop banner"
class:list={["hidden md:block object-cover h-full w-full transition duration-600 opacity-100"]}
src={imageSources.desktop[0] || imageSources.mobile[0] || ''}
position={config.position}
loading="eager"
/>
</div>
)}
<!-- Home page text overlay -->
{config.banner?.homeText?.enable && (
<div class={`banner-text-overlay absolute inset-0 z-20 flex items-center justify-center ${!showHomeText ? 'hidden' : ''}`}>
<div class="w-4/5 lg:w-3/4 text-center mb-0">
<div class="flex flex-col">
{config.banner?.homeText?.title && (
<h1 class="banner-title text-6xl lg:text-8xl text-white mb-2 lg:mb-4">
{config.banner.homeText.title}
</h1>
)}
{config.banner?.homeText?.subtitle && (
<h2 class="banner-subtitle text-xl lg:text-3xl text-white/90">
{config.banner.homeText.typewriter?.enable ? (
<TypewriterText
text={config.banner.homeText.subtitle}
speed={config.banner.homeText.typewriter.speed}
deleteSpeed={config.banner.homeText.typewriter.deleteSpeed}
pauseTime={config.banner.homeText.typewriter.pauseTime}
/>
) : (
Array.isArray(config.banner.homeText.subtitle)
? config.banner.homeText.subtitle[0]
: config.banner.homeText.subtitle
)}
</h2>
)}
</div>
</div>
</div>
)}
<!-- Water waves effect -->
{showWaves && (
<div class="waves-container absolute -bottom-px h-[10vh] max-h-37.5 min-h-12.5 w-full md:h-[15vh]" id="header-waves">
<svg
class="waves"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 21 150 30"
preserveAspectRatio="none"
shape-rendering="geometricPrecision"
>
<defs>
<path
id="gentle-wave"
d="M-160 44c30 0 58-18 88-18s 58 18 88 18 58-18 88-18 58 18 88 18 v48h-352z"
>
</path>
</defs>
<g class="parallax">
{isPerformanceMode ? (
// 性能模式:只渲染二个波浪层
<use
xlink:href="#gentle-wave"
x="48"
y="7"
class="fill-(--page-bg)"
></use>
<use
xlink:href="#gentle-wave"
x="48"
y="5"
class="opacity-75 fill-(--page-bg)"
></use>
) : (
// 正常模式:渲染四个波浪层
<>
<use
xlink:href="#gentle-wave"
x="48"
y="0"
class="opacity-25 fill-(--page-bg)"
></use>
<use
xlink:href="#gentle-wave"
x="48"
y="3"
class="opacity-50 fill-(--page-bg)"
></use>
<use
xlink:href="#gentle-wave"
x="48"
y="5"
class="opacity-75 fill-(--page-bg)"
></use>
<use
xlink:href="#gentle-wave"
x="48"
y="7"
class="fill-(--page-bg)"
></use>
</>
)}
</g>
</svg>
</div>
)}
</div>
<script define:vars={{ carouselInterval }}>
// 轮播图初始化函数
window.initBannerCarousel = function() {
const carousel = document.getElementById('banner-carousel');
if (!carousel) return;
// 检查是否是同一个 DOM 元素且定时器正在运行,避免重复初始化导致动画重置
if (window.bannerCarouselTimer && window.currentBannerCarousel === carousel) {
return;
}
window.currentBannerCarousel = carousel;
// 初始化全局状态
if (!window.bannerCarouselState) {
window.bannerCarouselState = {
currentIndex: 0,
lastSwitchTime: Date.now()
};
}
// 清理旧的定时器,防止重复初始化导致的闪烁
if (window.bannerCarouselTimer) {
clearTimeout(window.bannerCarouselTimer);
window.bannerCarouselTimer = null;
}
const carouselConfigData = carousel.getAttribute('data-carousel-config');
const carouselConfig = carouselConfigData ? JSON.parse(carouselConfigData) : {};
const desktopItems = carousel.querySelectorAll('.carousel-item.desktop-item');
const mobileItems = carousel.querySelectorAll('.carousel-item.mobile-item');
function setupCarousel(items, isEnabled) {
if (items.length === 0) return null;
// 如果禁用了轮播但有多张图,则随机显示一张
if (items.length > 1 && !isEnabled) {
items.forEach(item => {
item.classList.add('opacity-0');
item.classList.remove('opacity-100');
});
const randomIndex = Math.floor(Math.random() * items.length);
const randomItem = items[randomIndex];
randomItem.classList.add('opacity-100');
randomItem.classList.remove('opacity-0');
return null;
}
if (items.length > 1 && isEnabled) {
// 使用全局状态中的索引
let currentIndex = window.bannerCarouselState.currentIndex;
// 确保索引有效
if (currentIndex >= items.length) {
currentIndex = 0;
window.bannerCarouselState.currentIndex = 0;
}
function switchToSlide(index) {
const currentItem = items[currentIndex];
currentItem.classList.remove('opacity-100');
currentItem.classList.add('opacity-0');
currentIndex = index;
// 更新全局状态
window.bannerCarouselState.currentIndex = index;
window.bannerCarouselState.lastSwitchTime = Date.now();
const nextItem = items[currentIndex];
nextItem.classList.add('opacity-100');
nextItem.classList.remove('opacity-0');
// 通过切换 is-animating 类来重置下一张图片的动画
if (nextItem.classList.contains('ken-burns-enabled')) {
nextItem.classList.remove('is-animating');
nextItem.offsetHeight; // 强制重绘
nextItem.classList.add('is-animating');
}
}
// 初始化:根据当前索引显示图片
items.forEach((item, index) => {
if (index === currentIndex) {
item.classList.add('opacity-100');
item.classList.remove('opacity-0');
// 初始图片开启动画
if (item.classList.contains('ken-burns-enabled')) {
item.classList.add('is-animating');
}
} else {
item.classList.add('opacity-0');
item.classList.remove('opacity-100');
item.classList.remove('is-animating');
}
});
return {
next: () => switchToSlide((currentIndex + 1) % items.length),
prev: () => switchToSlide((currentIndex - 1 + items.length) % items.length),
};
}
return null;
}
const desktopCtrl = setupCarousel(desktopItems, carouselConfig?.enable);
const mobileCtrl = setupCarousel(mobileItems, carouselConfig?.enable);
if (carouselConfig?.enable && (desktopCtrl || mobileCtrl)) {
function startCarousel() {
if (window.bannerCarouselTimer) clearTimeout(window.bannerCarouselTimer);
const runLoop = () => {
const now = Date.now();
const intervalMs = carouselInterval * 1000;
const elapsed = now - window.bannerCarouselState.lastSwitchTime;
let delay = intervalMs - elapsed;
if (delay < 0) delay = 0;
window.bannerCarouselTimer = setTimeout(() => {
desktopCtrl?.next();
mobileCtrl?.next();
runLoop();
}, delay);
};
runLoop();
}
startCarousel();
}
}
// Banner显示控制函数
function showBanner() {
requestAnimationFrame(() => {
const banner = document.getElementById('banner');
if (banner) {
banner.classList.remove('opacity-0');
}
const mobileBanner = document.querySelector('.block.lg\\:hidden[alt="Mobile banner"]');
if (mobileBanner && !document.getElementById('banner-carousel')) {
mobileBanner.classList.remove('opacity-0');
mobileBanner.classList.add('opacity-100');
}
const carousel = document.getElementById('banner-carousel');
if (carousel) {
window.initBannerCarousel();
}
});
}
// 监听 Astro 页面切换事件
document.addEventListener('astro:after-swap', () => {
showBanner();
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', showBanner);
} else {
showBanner();
}
</script>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
/**
* 公共下拉面板选项组件 (Svelte 5 版本)
* 用于下拉面板中的选项项
*/
import type { Snippet } from "svelte";
interface Props {
isActive?: boolean;
isLast?: boolean;
class?: string;
onclick?: (event: MouseEvent) => void;
children?: Snippet;
}
let {
isActive = false,
isLast = false,
class: className = "",
onclick,
children,
...restProps
}: Props = $props();
const baseClasses =
"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";
// 使用 $derived 使类名响应式
const allClasses = $derived.by(() => {
const spacingClass = isLast ? "" : "mb-0.5";
const activeClass = isActive ? "current-theme-btn" : "";
return `${baseClasses} ${spacingClass} ${activeClass} ${className}`.trim();
});
</script>
<button
class={allClasses}
{onclick}
{...restProps}
>
{#if children}
{@render children()}
{/if}
</button>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
/**
* 公共下拉面板组件 (Svelte 5 版本)
* 用于壁纸切换、亮暗色切换等下拉面板
*/
import type { Snippet } from "svelte";
interface Props {
id?: string;
class?: string;
children?: Snippet;
element?: HTMLElement;
}
let { id, class: className = "", children, element = $bindable(), ...restProps }: Props = $props();
</script>
<div {id} bind:this={element} class={`card-base float-panel p-2 ${className}`.trim()} {...restProps}>
{#if children}
{@render children()}
{/if}
</div>

View File

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

View File

@@ -0,0 +1,14 @@
---
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-(--btn-content) dark:bg-(--card-bg) transition rounded-md mr-2"></div>}
<slot></slot>
</a>

View File

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

View File

@@ -0,0 +1,266 @@
---
// 全局Iconify加载器组件
// 在页面头部加载,确保图标库尽早可用
export interface Props {
preloadIcons?: string[]; // 需要预加载的图标列表
timeout?: number; // 加载超时时间
retryCount?: number; // 重试次数
}
const { preloadIcons = [], timeout = 10000, retryCount = 3 } = Astro.props;
---
<!-- Iconify图标库加载器 -->
<script 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) => window.__iconifyLoader.onLoad(callback);
// 页面可见性变化时重新检查
document.addEventListener('visibilitychange', () => {
if (!document.hidden && !window.__iconifyLoader.isLoaded) {
window.__iconifyLoader.load().catch(console.error);
}
});
})();
</script>
<!-- 为不支持JavaScript的情况提供备用方案 -->
<noscript>
<style>
iconify-icon {
display: none;
}
.icon-fallback {
display: inline-block;
}
</style>
</noscript>

View File

@@ -0,0 +1,61 @@
---
import type { ImageMetadata } from "astro";
import { Image } from "astro:assets";
import path from "node:path";
import { url } from "@utils/url";
interface Props {
id?: string;
src: string;
class?: string;
alt?: string;
position?: string;
basePath?: string;
style?: string;
loading?: "lazy" | "eager";
decoding?: "async" | "auto" | "sync";
}
const { id, src, alt, position = "center", basePath = "/", style, loading = "lazy", decoding = "async" } = 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;
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']} style={style}>
<div class="transition absolute inset-0 bg-black/3 dark:bg-black/9 pointer-events-none"></div>
{isLocal && img && <Image src={img} alt={alt || ""} class={imageClass} style={imageStyle} loading={loading} decoding={decoding}/>}
{!isLocal && <img src={isPublic ? url(src) : src} alt={alt || ""} class={imageClass} style={imageStyle} loading={loading} decoding={decoding}/>}
</div>

View File

@@ -0,0 +1,16 @@
---
// 只加载基础的等宽字体,减少加载时间
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>

View File

@@ -0,0 +1,285 @@
---
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import CryptoJS from 'crypto-js';
interface Props {
isEncrypted?: boolean;
password?: string;
}
const { isEncrypted = false, password = "" } = Astro.props;
let encryptedContent = "";
if (isEncrypted) {
const html = await Astro.slots.render('default');
encryptedContent = CryptoJS.AES.encrypt(html, password).toString();
}
---
{!isEncrypted ? (
<slot />
) : (
<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 class="text-90">{i18n(I18nKey.passwordProtectedTitle)}</h2>
<p class="text-75">{i18n(I18nKey.passwordProtectedDescription)}</p>
<div class="password-input-group">
<div class="password-input-wrapper">
<input
type="password"
id="password-input"
placeholder={i18n(I18nKey.passwordPlaceholder)}
class="password-input text-90"
autocomplete="current-password"
/>
<button id="toggle-password" class="toggle-password-btn text-50" type="button" aria-label="Toggle password visibility">
<svg id="eye-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<svg id="eye-off-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: none;">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
<line x1="1" y1="1" x2="23" y2="23"></line>
</svg>
</button>
</div>
<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;" data-encrypted={encryptedContent}></div>
<script>
import CryptoJS from 'crypto-js';
function initPasswordProtection() {
const passwordInput = document.getElementById('password-input') as HTMLInputElement;
const unlockBtn = document.getElementById('unlock-btn');
const togglePasswordBtn = document.getElementById('toggle-password');
const eyeIcon = document.getElementById('eye-icon');
const eyeOffIcon = document.getElementById('eye-off-icon');
const errorMessage = document.getElementById('error-message');
const passwordProtection = document.getElementById('password-protection');
const decryptedContent = document.getElementById('decrypted-content');
if (!passwordInput || !unlockBtn || !decryptedContent || !passwordProtection) return;
// 切换密码可见性
if (togglePasswordBtn && eyeIcon && eyeOffIcon) {
togglePasswordBtn.addEventListener('click', () => {
const isPassword = passwordInput.type === 'password';
passwordInput.type = isPassword ? 'text' : 'password';
eyeIcon.style.display = isPassword ? 'none' : 'block';
eyeOffIcon.style.display = isPassword ? 'block' : 'none';
});
}
const handleUnlock = () => {
const password = passwordInput.value;
const encrypted = decryptedContent.getAttribute('data-encrypted');
if (!password || !encrypted) return;
try {
const bytes = CryptoJS.AES.decrypt(encrypted, password);
const decryptedHtml = bytes.toString(CryptoJS.enc.Utf8);
if (decryptedHtml) {
decryptedContent.innerHTML = decryptedHtml;
decryptedContent.style.display = 'block';
passwordProtection.style.display = 'none';
// 执行解密内容中的脚本
decryptedContent.querySelectorAll('script').forEach(oldScript => {
const newScript = document.createElement('script');
Array.from(oldScript.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value));
newScript.appendChild(document.createTextNode(oldScript.innerHTML));
oldScript.parentNode?.replaceChild(newScript, oldScript);
});
// 触发可能需要的重新渲染或初始化
window.dispatchEvent(new Event('resize'));
window.dispatchEvent(new CustomEvent('content-decrypted'));
} else {
if (errorMessage) errorMessage.style.display = 'block';
}
} catch (e) {
if (errorMessage) errorMessage.style.display = 'block';
}
};
unlockBtn.addEventListener('click', handleUnlock);
passwordInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') handleUnlock();
if (errorMessage) errorMessage.style.display = 'none';
});
}
// 页面加载时初始化
initPasswordProtection();
// 支持 View Transitions
document.addEventListener('astro:after-swap', initPasswordProtection);
</script>
)}
<style>
.password-protection {
display: flex;
justify-content: center;
align-items: center;
min-height: 40vh;
padding: 2rem;
}
.password-container {
text-align: center;
max-width: 400px;
width: 100%;
padding: 2.5rem;
border-radius: 1rem;
background: var(--card-bg);
border: 1px solid var(--line-divider);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
}
.lock-icon {
margin-bottom: 1.5rem;
color: var(--primary);
display: flex;
justify-content: center;
}
.password-container h2 {
margin-bottom: 0.75rem;
font-size: 1.5rem;
font-weight: 700;
}
.password-container p {
margin-bottom: 2rem;
font-size: 0.95rem;
line-height: 1.5;
}
.password-input-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
.password-input-wrapper {
position: relative;
width: 100%;
}
.password-input {
width: 100%;
padding: 0.75rem 3rem 0.75rem 1rem;
border: 1px solid var(--line-divider);
border-radius: 0.5rem;
background: var(--card-bg);
font-size: 1rem;
transition: all 0.2s ease;
}
.password-input::-ms-reveal,
.password-input::-ms-clear {
display: none;
}
.toggle-password-btn {
position: absolute;
right: 0.75rem;
top: 50%;
translate: 0 -50%;
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s ease;
}
.toggle-password-btn:hover {
color: var(--primary);
}
.password-input::placeholder {
color: var(--content-meta);
opacity: 0.7;
}
.password-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
}
.unlock-button {
width: 100%;
padding: 0.75rem 1.5rem;
background: var(--primary);
color: white;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.unlock-button:hover {
background: var(--primary-hover);
translate: 0 -1px;
}
.unlock-button:active {
translate: 0 0;
}
.error-message {
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.75rem;
font-weight: 500;
}
.decrypted-content {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
translate: 0 10px;
}
to {
opacity: 1;
translate: 0 0;
}
}
/* 响应式适配 */
@media (max-width: 768px) {
.password-protection {
padding: 1rem;
min-height: 30vh;
}
.password-container {
padding: 1.5rem;
}
}
</style>

View File

@@ -0,0 +1,154 @@
---
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);
}
// 如果只有一条文本,保持显示不删除
}
}
}
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;
}
.typewriter::after {
content: '|';
animation: blink 1s infinite;
margin-left: 2px;
}
@keyframes blink {
0%, 50% {
opacity: 1;
}
51%, 100% {
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,13 @@
---
import { siteConfig } from "@/config";
---
<!-- 全局配置载体 -->
<div
id="config-carrier"
data-hue={siteConfig.themeColor.hue}
data-lang={siteConfig.lang}
data-theme={siteConfig.defaultTheme}
data-wallpaper-mode={siteConfig.wallpaper.mode}
>
</div>

View File

@@ -0,0 +1,179 @@
---
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
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-100 text-green-700 dark:bg-green-900/30 dark:text-green-400";
case "in-progress":
return "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400";
case "planned":
return "bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400";
default:
return "bg-gray-100 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-white dark:bg-gray-800 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-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400 rounded-sm">
{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-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-sm ${sizeClasses.tech}`}>
{tech}
</span>
))}
{project.techStack.length > maxTechStack && (
<span class={`px-2 py-1 bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400 rounded-sm ${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-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400 rounded-sm ${sizeClasses.tech}`}>
#{tag}
</span>
))}
</div>
)}
<!-- 链接 -->
<div class="flex gap-3">
{project.demoUrl && (
<a
href={project.demoUrl}
target="_blank"
rel="noopener noreferrer"
class={`inline-flex items-center px-3 py-1.5 rounded-md border border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-300 bg-blue-50/60 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/40 font-medium transition-colors ${sizeClasses.links}`}
>
{i18n(I18nKey.projectsDemo)}
</a>
)}
{project.sourceUrl && (
<a
href={project.sourceUrl}
target="_blank"
rel="noopener noreferrer"
class={`inline-flex items-center px-3 py-1.5 rounded-md border border-gray-400 text-gray-700 dark:border-gray-500 dark:text-gray-200 bg-gray-50/80 dark:bg-gray-800/60 hover:bg-gray-100 dark:hover:bg-gray-700 font-medium transition-colors ${sizeClasses.links}`}
>
{i18n(I18nKey.projectsSource)}
</a>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,219 @@
---
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import Icon from "@components/common/icon.astro";
export interface Props {
skill: {
id: string;
name: string;
description: string;
icon?: string;
category: string;
level: "beginner" | "intermediate" | "advanced" | "expert";
experience: string | { years: number; months: number };
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-100 text-red-700 dark:bg-red-900/30 dark:text-red-400";
case "advanced":
return "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400";
case "intermediate":
return "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400";
case "beginner":
return "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400";
default:
return "bg-gray-100 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";
// 经验展示文本
const getExperienceText = (experience: Props["skill"]["experience"]) => {
if (typeof experience === "string") return experience;
const yearsText = `${experience.years}${i18n(I18nKey.skillYears)}`;
const monthsText =
experience.months > 0
? ` ${experience.months}${i18n(I18nKey.skillMonths)}`
: "";
return `${yearsText}${monthsText}`;
};
---
<div class="bg-white dark:bg-slate-800/50 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-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400 rounded-sm ${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">{getExperienceText(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-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 rounded-sm ${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>
<script>
// 监听图标加载完成事件
document.addEventListener('DOMContentLoaded', () => {
const skillCard = document.currentScript?.parentElement;
if (skillCard) {
skillCard.classList.add('skill-card');
// 监听图标准备就绪事件
skillCard.addEventListener('iconify-ready', () => {
// 图标加载完成,可以执行额外的初始化逻辑
skillCard.classList.add('icons-loaded');
});
}
});
</script>

View File

@@ -0,0 +1,372 @@
---
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import Icon from "@components/common/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: "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-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400";
case "work":
return "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400";
case "project":
return "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400";
case "achievement":
return "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400";
default:
return "bg-gray-100 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 "other":
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)}`;
} else {
const years = Math.floor(diffMonths / 12);
const months = diffMonths % 12;
if (months === 0) {
return `${years} ${i18n(I18nKey.timelineYears)}`;
} else {
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-white dark:bg-gray-800 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-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-sm ${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={`inline-flex items-center px-3 py-1.5 rounded-md border border-(--primary) text-(--primary) bg-[color-mix(in_oklch,var(--primary)_8%,transparent)] hover:bg-[color-mix(in_oklch,var(--primary)_14%,transparent)] active:bg-[color-mix(in_oklch,var(--primary)_20%,transparent)] text-sm font-medium transition-colors gap-1 ${sizeClasses.meta}`}
>
{getLinkIcon(link.type)}
{link.name}
</a>
))}
</div>
)}
</div>
</div>
</div>
) : (
<!-- 卡片布局 -->
<div class="bg-white dark:bg-gray-800 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`}>
<Icon
icon={item.icon || getTypeIcon(item.type)}
class={sizeClasses.nodeIcon}
color={itemColor}
fallback={item.title.charAt(0)}
loading="eager"
/>
</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-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-sm ${sizeClasses.badge}`}>
{skill}
</span>
))}
{item.skills.length > 3 && (
<span class={`px-2 py-1 bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400 rounded-sm ${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={`inline-flex items-center px-3 py-1.5 rounded-md border border-(--primary) text-(--primary) bg-[color-mix(in_oklch,var(--primary)_8%,transparent)] hover:bg-[color-mix(in_oklch,var(--primary)_14%,transparent)] active:bg-[color-mix(in_oklch,var(--primary)_20%,transparent)] text-sm font-medium transition-colors ${sizeClasses.meta}`}
>
{getLinkIcon(link.type)} {link.name}
</a>
))}
</div>
)}
</div>
</div>
</div>
)}

View File

@@ -0,0 +1,75 @@
---
import { siteConfig } from "@/config";
// Get all fonts from config
const fontConfig = siteConfig.font || {};
const fonts = Object.entries(fontConfig).map(([id, font]) => (
{id, ...font}
));
// Generate font-family strings
const selectedFontFamilies = fonts
.filter(font => font && font.family)
.map(font => `"${font.family}"`);
const fallbacks = "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif";
const customFontFamily = selectedFontFamilies.join(", ");
const finalFontFamily = customFontFamily ? `${customFontFamily}, ${fallbacks}` : fallbacks;
---
<!-- Font Loading -->
{
fonts.map((font) => {
const isCss = font.src.endsWith('.css') || font.src.includes('fonts.googleapis.com') || font.src.includes('unpkg.com');
if (isCss) {
// If it's a Google Font, we can append &display=swap if it's not already there
let fontSrc = font.src;
if (fontSrc.includes('fonts.googleapis.com') && !fontSrc.includes('display=')) {
fontSrc += (fontSrc.includes('?') ? '&' : '?') + 'display=swap';
}
return (
<>
<link rel="preload" href={fontSrc} as="style" />
<link rel="stylesheet" href={fontSrc} />
</>
);
} else if (font.src) {
// Assume it's a font file (ttf, woff, woff2, etc.)
return (
<>
<link rel="preload" href={font.src} as="font" crossorigin="anonymous" />
<style set:html={`
@font-face {
font-family: "${font.family}";
src: url("${font.src}");
font-display: swap;
}
`} />
</>
);
}
return null;
})
}
<!-- Apply Global Font Family -->
<style is:global set:html={`
:root {
--font-family-fallback: ${fallbacks};
--global-font-family: ${finalFontFamily};
}
/* Apply to body as default */
body {
font-family: var(--global-font-family);
}
/* Generate specific classes for each font*/
${fonts.map(font => `
.font-${font.id},
.font-${font.id} * {
font-family: "${font.family}", var(--font-family-fallback) !important;
}
`).join('\n')}
`} />

View File

@@ -0,0 +1,50 @@
---
import * as fs from "node:fs";
import * as path from "node:path";
import { footerConfig, profileConfig } from "@/config";
import { url } from "@utils/url";
const currentYear = new Date().getFullYear();
// HTML 内容获取逻辑
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(),
"public",
"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="transition border-t border-black/10 dark:border-white/15 my-10 border-dashed mx-32"></div>
<div class="transition border-dashed border-[oklch(85%_0.01_var(--hue))] dark:border-white/15 rounded-2xl mb-12 flex flex-col items-center justify-center px-6">
<div class="transition text-50 text-sm text-center">
{customFooterHtml && (
<div class="mb-2" set:html={customFooterHtml}></div>
)}
&copy; <span id="copyright-year">{currentYear}</span> {profileConfig.name}. All Rights Reserved. /
<a class="transition link text-(--primary) font-medium" href={url('rss/')} onclick={`event.preventDefault(); navigateToPage('${url('rss/')}')`}>RSS</a> /
<a class="transition link text-(--primary) font-medium" href={url('atom/')} onclick={`event.preventDefault(); navigateToPage('${url('atom/')}')`}>Atom</a> /
<a class="transition link text-(--primary) font-medium" target="_blank" href={url('sitemap-index.xml')}>Sitemap</a><br>
Powered by
<a class="transition link text-(--primary) font-medium" target="_blank" href="https://astro.build">Astro</a> &
<a class="transition link text-(--primary) font-medium" target="_blank" href="https://github.com/Spr-Aachen/Twilight">Twilight</a>&nbsp; Version <a class="transition link text-(--primary) font-medium" target="_blank" href="https://github.com/Spr-Aachen/Twilight/releases">1.0</a><br>
</div>
</div>

View File

@@ -0,0 +1,267 @@
---
import type { SiteConfig } from "@/types/config";
import ImageWrapper from "@components/common/imageWrapper.astro";
interface Props {
config: SiteConfig["wallpaper"];
class?: string;
}
const { config, class: className } = Astro.props;
// 获取当前设备类型的图片源
const getImageSources = () => {
const toArray = (src: any) => [src || []].flat();
const { src } = config;
const isObj = src && typeof src === "object" && !Array.isArray(src);
const desktop = toArray(isObj ? (src as any).desktop : src);
const mobile = toArray(isObj ? (src as any).mobile : src);
return {
desktop: desktop.length > 0 ? desktop : mobile,
mobile: mobile.length > 0 ? mobile : desktop,
};
}
const imageSources = getImageSources();
// 轮播配置
const carouselConfig = config.carousel;
const isCarouselEnabled = imageSources.desktop.length > 1 || imageSources.mobile.length > 1;
const carouselInterval = carouselConfig?.interval || 5;
// 样式配置
const zIndex = config.fullscreen?.zIndex || -1;
const opacity = config.fullscreen?.opacity || 0.8;
const blur = config.fullscreen?.blur || 0;
---
<div
id="fullscreen-wallpaper-wrapper"
class:list={[
"fixed inset-0 w-full h-full overflow-hidden pointer-events-none transition-opacity duration-600",
className
]}
style={`z-index: ${zIndex}; opacity: ${opacity};`}
data-fullscreen-wallpaper
>
{isCarouselEnabled ? (
<div id="fullscreen-wallpaper-carousel" class="relative h-full w-full" data-carousel-config={JSON.stringify(carouselConfig)}>
<ul class="carousel-list h-full w-full">
{imageSources.desktop.map((src, index) => (
<li class:list={[
"carousel-item desktop-item hidden md:block absolute inset-0 transition-opacity duration-1200",
index === 0 ? 'opacity-100' : 'opacity-0',
carouselConfig?.kenBurns !== false ? 'ken-burns-enabled' : ''
]}>
<ImageWrapper
alt={`Desktop wallpaper ${index + 1}`}
class:list={["object-cover h-full w-full"]}
src={src}
position={config.position}
style={blur > 0 ? `filter: blur(${blur}px);` : undefined}
loading={index === 0 ? "eager" : "lazy"}
/>
</li>
))}
{imageSources.mobile.map((src, index) => (
<li class:list={[
"carousel-item mobile-item block md:hidden absolute inset-0 transition-opacity duration-1200",
index === 0 ? 'opacity-100' : 'opacity-0',
carouselConfig?.kenBurns !== false ? 'ken-burns-enabled' : ''
]}>
<ImageWrapper
alt={`Mobile wallpaper ${index + 1}`}
class:list={["object-cover h-full w-full"]}
src={src}
position={config.position}
style={blur > 0 ? `filter: blur(${blur}px);` : undefined}
loading={index === 0 ? "eager" : "lazy"}
/>
</li>
))}
</ul>
</div>
) : (
<div class="relative h-full w-full">
<ImageWrapper
alt="Mobile wallpaper"
class:list={["block md:hidden object-cover h-full w-full transition duration-600 opacity-100"]}
src={imageSources.mobile[0] || imageSources.desktop[0] || ''}
position={config.position}
style={blur > 0 ? `filter: blur(${blur}px);` : undefined}
loading="eager"
/>
<ImageWrapper
id="fullscreen-wallpaper"
alt="Desktop wallpaper"
class:list={["hidden md:block object-cover h-full w-full transition duration-600 opacity-100"]}
src={imageSources.desktop[0] || imageSources.mobile[0] || ''}
position={config.position}
style={blur > 0 ? `filter: blur(${blur}px);` : undefined}
loading="eager"
/>
</div>
)}
</div>
<script define:vars={{ carouselInterval }}>
// 全屏壁纸轮播图初始化函数
window.initFullscreenWallpaperCarousel = function() {
const carousel = document.getElementById('fullscreen-wallpaper-carousel');
if (!carousel) return;
// 检查是否是同一个 DOM 元素且定时器正在运行,避免重复初始化导致动画重置
if (window.fullscreenWallpaperTimer && window.currentFullscreenWallpaperCarousel === carousel) {
return;
}
window.currentFullscreenWallpaperCarousel = carousel;
// 初始化全局状态
if (!window.fullscreenWallpaperState) {
window.fullscreenWallpaperState = {
currentIndex: 0,
lastSwitchTime: Date.now()
};
}
// 清理旧的定时器,防止重复初始化导致的闪烁
if (window.fullscreenWallpaperTimer) {
clearTimeout(window.fullscreenWallpaperTimer);
window.fullscreenWallpaperTimer = null;
}
const carouselConfigData = carousel.getAttribute('data-carousel-config');
const carouselConfig = carouselConfigData ? JSON.parse(carouselConfigData) : {};
const desktopItems = carousel.querySelectorAll('.carousel-item.desktop-item');
const mobileItems = carousel.querySelectorAll('.carousel-item.mobile-item');
function setupCarousel(items, isEnabled) {
if (items.length === 0) return null;
// 如果禁用了轮播但有多张图,则随机显示一张
if (items.length > 1 && !isEnabled) {
items.forEach(item => {
item.classList.add('opacity-0');
item.classList.remove('opacity-100');
});
const randomIndex = Math.floor(Math.random() * items.length);
const randomItem = items[randomIndex];
randomItem.classList.add('opacity-100');
randomItem.classList.remove('opacity-0');
return null;
}
if (items.length > 1 && isEnabled) {
// 使用全局状态中的索引
let currentIndex = window.fullscreenWallpaperState.currentIndex;
// 确保索引有效
if (currentIndex >= items.length) {
currentIndex = 0;
window.fullscreenWallpaperState.currentIndex = 0;
}
function switchToSlide(index) {
const currentItem = items[currentIndex];
currentItem.classList.remove('opacity-100');
currentItem.classList.add('opacity-0');
currentIndex = index;
// 更新全局状态
window.fullscreenWallpaperState.currentIndex = index;
window.fullscreenWallpaperState.lastSwitchTime = Date.now();
const nextItem = items[currentIndex];
nextItem.classList.add('opacity-100');
nextItem.classList.remove('opacity-0');
// 通过切换 is-animating 类来重置下一张图片的动画
if (nextItem.classList.contains('ken-burns-enabled')) {
nextItem.classList.remove('is-animating');
nextItem.offsetHeight; // 强制重绘
nextItem.classList.add('is-animating');
}
}
// 初始化:根据当前索引显示图片
items.forEach((item, index) => {
if (index === currentIndex) {
item.classList.add('opacity-100');
item.classList.remove('opacity-0');
// 初始图片开启动画
if (item.classList.contains('ken-burns-enabled')) {
item.classList.add('is-animating');
}
} else {
item.classList.add('opacity-0');
item.classList.remove('opacity-100');
item.classList.remove('is-animating');
}
});
return {
next: () => switchToSlide((currentIndex + 1) % items.length),
prev: () => switchToSlide((currentIndex - 1 + items.length) % items.length),
};
}
return null;
}
const desktopCtrl = setupCarousel(desktopItems, carouselConfig?.enable);
const mobileCtrl = setupCarousel(mobileItems, carouselConfig?.enable);
if (carouselConfig?.enable && (desktopCtrl || mobileCtrl)) {
function startCarousel() {
if (window.fullscreenWallpaperTimer) clearTimeout(window.fullscreenWallpaperTimer);
const runLoop = () => {
const now = Date.now();
const intervalMs = carouselInterval * 1000;
const elapsed = now - window.fullscreenWallpaperState.lastSwitchTime;
let delay = intervalMs - elapsed;
if (delay < 0) delay = 0;
window.fullscreenWallpaperTimer = setTimeout(() => {
desktopCtrl?.next();
mobileCtrl?.next();
runLoop();
}, delay);
};
runLoop();
}
startCarousel();
}
}
// 壁纸显示控制函数
function showFullscreenWallpaper() {
requestAnimationFrame(() => {
const wallpaper = document.getElementById('fullscreen-wallpaper');
if (wallpaper) {
wallpaper.classList.remove('opacity-0');
}
const mobileWallpaper = document.querySelector('.block.md\\:hidden[alt="Mobile wallpaper"]');
if (mobileWallpaper && !document.getElementById('fullscreen-wallpaper-carousel')) {
mobileWallpaper.classList.remove('opacity-0');
mobileWallpaper.classList.add('opacity-100');
}
const carousel = document.getElementById('fullscreen-wallpaper-carousel');
if (carousel) {
window.initFullscreenWallpaperCarousel();
}
});
}
// 监听 Astro 页面切换事件
document.addEventListener('astro:after-swap', () => {
showFullscreenWallpaper();
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', showFullscreenWallpaper);
} else {
showFullscreenWallpaper();
}
</script>

View File

@@ -0,0 +1,93 @@
---
import { siteConfig } from "@/config";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
const config = siteConfig.loadingOverlay;
const showOverlay = config?.enable ?? true;
const showSpinner = config?.spinner?.enable ?? true;
const showTitle = config?.title?.enable ?? true;
const titleContent = config?.title?.content ?? i18n(I18nKey.loading);
const spinnerInterval = config?.spinner?.interval ?? 1.5;
const titleInterval = config?.title?.interval ?? 1.5;
---
{showOverlay && (
<div
id="loading-overlay"
class="fixed inset-0 z-100 flex items-center justify-center bg-(--page-bg) transition-opacity duration-600"
>
<div class="flex flex-col items-center">
<!-- loading icon -->
{showSpinner && <div class="loading-spinner mb-4"></div>}
<!-- loading title -->
{showTitle && (
<div
class="loading-title text-(--primary) font-medium tracking-widest"
>
{titleContent}
</div>
)}
</div>
</div>
)}
<style define:vars={{ spinnerInterval: `${spinnerInterval}s`, titleInterval: `${titleInterval}s` }}>
@keyframes spin {
0% {
rotate: 0deg;
}
100% {
rotate: 360deg;
}
}
@keyframes spinner-glow {
0%,
100% {
box-shadow: 0 0 12px var(--primary);
border-top-color: var(--primary);
}
50% {
box-shadow: 0 0 24px var(--primary);
border-top-color: var(--primary);
}
}
.loading-spinner {
width: 48px;
height: 48px;
border: 3px solid color-mix(in oklch, var(--primary) 10%, transparent);
border-top: 3px solid var(--primary);
border-radius: 50%;
animation:
spin var(--spinnerInterval) linear infinite,
spinner-glow var(--spinnerInterval) ease-in-out infinite;
}
@keyframes text-glow {
0%,
100% {
opacity: 0.6;
text-shadow: 0 0 12px var(--primary);
}
50% {
opacity: 1;
text-shadow: 0 0 24px var(--primary);
}
}
.loading-title {
animation: text-glow var(--titleInterval) ease-in-out infinite;
}
/* 手动触发淡出类 */
#loading-overlay.fade-out {
opacity: 0;
pointer-events: none;
}
/* 当 html 移除 is-loading 类时,确保 overlay 隐藏 */
:global(html:not(.is-loading)) #loading-overlay {
opacity: 0;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,796 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { slide } from "svelte/transition";
import Icon from "@iconify/svelte";
import type { MusicPlayerTrack } from "@/types/config";
import { musicPlayerConfig } from "@/config";
import { i18n } from "@i18n/translation";
import Key from "@i18n/i18nKey";
import "@styles/musicplayer.css";
// 音乐播放器模式,可选 "local" 或 "meting"
let mode = $state(musicPlayerConfig.mode ?? "meting");
// Meting API 地址,从配置中获取或使用默认值
let meting_api = musicPlayerConfig.meting?.meting_api ?? "https://api.i-meto.com/meting/api";
// Meting API 的数据源,从配置中获取或使用默认值
let meting_server = musicPlayerConfig.meting?.server ?? "netease";
// Meting API 的类型,从配置中获取或使用默认值
let meting_type = musicPlayerConfig.meting?.type ?? "playlist";
// Meting API 的 ID从配置中获取或使用默认值
let meting_id = musicPlayerConfig.meting?.id ?? "2161912966";
// 是否启用自动播放,从配置中获取或使用默认值
let isAutoplayEnabled = $state(musicPlayerConfig.autoplay ?? false);
// 当前歌曲信息
let currentSong: MusicPlayerTrack = $state({
id: 0,
title: "Music",
artist: "Artist",
cover: "/favicon/icon-light.ico",
url: "",
duration: 0,
});
let playlist: MusicPlayerTrack[] = $state([]);
let currentIndex = $state(0);
let audio: HTMLAudioElement | undefined = $state();
let progressBar: HTMLElement | undefined = $state();
let volumeBar: HTMLElement | undefined = $state();
// 是否正在播放
let isPlaying = $state(false);
// 是否应该播放(用于切换歌曲时的自动播放)
let shouldPlay = $state(false);
// 是否折叠播放器
let isCollapsed = $state(true);
// 是否显示播放列表
let showPlaylist = $state(false);
// 当前播放时间
let currentTime = $state(0);
// 歌曲总时长
let duration = $state(0);
// 音量
let volume = $state(0.75);
// 是否静音
let isMuted = $state(false);
// 是否正在加载
let isLoading = $state(false);
// 是否随机播放
let isShuffled = $state(false);
// 循环模式0: 不循环, 1: 单曲循环, 2: 列表循环
let isRepeating = $state(0);
// 待恢复的进度
let pendingProgress = $state(0);
// 上次保存进度的时间,用于节流
let lastSaveTime = 0;
// 错误信息
let errorMessage = $state("");
// 是否显示错误信息
let showError = $state(false);
// 存储键名常量
const STORAGE_KEYS = {
USER_PAUSED: "player_user_paused",
VOLUME: "player_volume",
SHUFFLE: "player_shuffle",
REPEAT: "player_repeat",
LAST_SONG_ID: "player_last_song_id",
LAST_SONG_PROGRESS: "player_last_song_progress",
};
function restoreLastSong() {
if (playlist.length === 0) return;
if (typeof localStorage !== 'undefined') {
const lastId = localStorage.getItem(STORAGE_KEYS.LAST_SONG_ID);
let index = -1;
// 优先通过 ID 匹配
if (lastId) {
index = playlist.findIndex(s => s.id !== undefined && String(s.id) === String(lastId));
}
if (index !== -1) {
currentIndex = index;
// 获取保存的进度
const savedProgress = localStorage.getItem(STORAGE_KEYS.LAST_SONG_PROGRESS);
if (savedProgress) {
pendingProgress = parseFloat(savedProgress);
}
loadSong(playlist[currentIndex]);
return;
}
}
// 如果没有找到上次播放的歌曲,或者没有记录,加载第一首
currentIndex = 0;
loadSong(playlist[0]);
}
function showErrorMessage(message: string) {
errorMessage = message;
showError = true;
setTimeout(() => {
showError = false;
}, 3000);
}
async function fetchMetingPlaylist() {
if (!meting_api || !meting_id) return;
isLoading = true;
const query = new URLSearchParams({
server: meting_server,
type: meting_type,
id: meting_id,
});
const separator = meting_api.includes("?") ? "&" : "?";
const apiUrl = `${meting_api}${separator}${query.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: any, index: number) => {
let title = song.name ?? song.title ?? i18n(Key.musicUnknownTrack);
let artist = song.artist ?? song.author ?? i18n(Key.musicUnknownArtist);
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 ?? `meting-${index}`, // 确保每个歌曲都有 ID
title,
artist,
cover: song.pic ?? "",
url: song.url ?? "",
duration: dur,
};
});
if (playlist.length > 0) {
// 使用 setTimeout 确保 Svelte 响应式变量已更新
setTimeout(() => {
restoreLastSong();
}, 0);
}
isLoading = false;
} catch (e) {
showErrorMessage(i18n(Key.musicMetingFailed));
isLoading = false;
}
}
async function toggleMode() {
if (!musicPlayerConfig.enable) return;
mode = mode === "meting" ? "local" : "meting";
showPlaylist = false;
isLoading = false;
isPlaying = false;
currentIndex = 0;
playlist = [];
if (audio) {
audio.pause();
audio.currentTime = 0;
}
currentTime = 0;
duration = 0;
if (mode === "meting") {
await fetchMetingPlaylist();
} else {
playlist = [...(musicPlayerConfig.local?.playlist ?? [])];
if (playlist.length > 0) {
setTimeout(() => {
restoreLastSong();
}, 0);
} else {
showErrorMessage(i18n(Key.musicEmptyPlaylist));
}
}
}
function togglePlay() {
if (!audio || !currentSong.url) return;
if (isPlaying) {
audio.pause();
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEYS.USER_PAUSED, "true");
}
} else {
audio.play();
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEYS.USER_PAUSED, "false");
}
}
}
function toggleCollapse() {
isCollapsed = !isCollapsed;
if (isCollapsed) {
showPlaylist = false;
}
}
function togglePlaylist() {
showPlaylist = !showPlaylist;
}
function toggleShuffle() {
isShuffled = !isShuffled;
if (isShuffled) {
isRepeating = 0;
}
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEYS.SHUFFLE, String(isShuffled));
localStorage.setItem(STORAGE_KEYS.REPEAT, String(isRepeating));
}
}
function toggleRepeat() {
isRepeating = (isRepeating + 1) % 3;
if (isRepeating !== 0) {
isShuffled = false;
}
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEYS.REPEAT, String(isRepeating));
localStorage.setItem(STORAGE_KEYS.SHUFFLE, String(isShuffled));
}
}
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;
currentIndex = index;
// 用户手动选择歌曲(或自动切换),标记为应该播放
shouldPlay = true;
// 用户手动选择歌曲,清除暂停偏好和待恢复进度
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEYS.USER_PAUSED, "false");
}
pendingProgress = 0;
// 加载歌曲
loadSong(playlist[currentIndex]);
}
function getAssetPath(path: string): string {
if (path.startsWith("http://") || path.startsWith("https://")) return path;
if (path.startsWith("/")) return path;
return `/${path}`;
}
function loadSong(song: MusicPlayerTrack) {
if (!song || !audio) return;
currentSong = { ...song };
// 记录最后播放的歌曲 ID (排除初始化的占位符 ID 0)
if (typeof localStorage !== 'undefined' && song.id !== undefined && song.id !== 0) {
localStorage.setItem(STORAGE_KEYS.LAST_SONG_ID, String(song.id));
// 如果不是恢复进度的情况,重置保存的进度
if (pendingProgress <= 0) {
localStorage.setItem(STORAGE_KEYS.LAST_SONG_PROGRESS, "0");
}
}
if (song.url) {
isLoading = true;
// 如果有待恢复的进度,先不要重置为 0以免进度条跳变
if (pendingProgress > 0) {
currentTime = pendingProgress;
} else {
audio.currentTime = 0;
currentTime = 0;
}
duration = song.duration ?? 0;
audio.removeEventListener("canplay", handleLoadSuccess);
audio.removeEventListener("error", handleLoadError);
audio.removeEventListener("loadstart", handleLoadStart);
audio.addEventListener("canplay", 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;
}
}
let autoplayFailed = $state(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;
}
// 恢复进度
if (pendingProgress > 0 && audio) {
// 确保进度不超出总时长
const targetTime = Math.min(pendingProgress, duration > 0 ? duration : Infinity);
audio.currentTime = targetTime;
currentTime = targetTime;
pendingProgress = 0; // 恢复后清除
}
// 如果是自动播放模式,或者当前处于播放状态(如切换歌曲),则尝试播放
if (isAutoplayEnabled || isPlaying || shouldPlay) {
const playPromise = audio?.play();
if (playPromise !== undefined) {
playPromise.then(() => {
// 播放成功后,关闭自动播放标记,后续由用户控制
isAutoplayEnabled = false;
autoplayFailed = false;
shouldPlay = false;
}).catch((error) => {
showErrorMessage(i18n(Key.musicAutoplayBlocked));
autoplayFailed = true;
// 确保 UI 状态为暂停
isPlaying = false;
shouldPlay = false;
});
}
}
}
function handleUserInteraction() {
// 如果自动播放失败且尚未开始播放,则在用户交互时尝试播放
if (autoplayFailed && audio && !isPlaying) {
const playPromise = audio.play();
if (playPromise !== undefined) {
playPromise.then(() => {
autoplayFailed = false;
}).catch(() => {});
}
}
}
function handleLoadError(event: Event) {
isLoading = false;
showErrorMessage(i18n(Key.musicPlayFailed).replace("{0}", currentSong.title));
if (playlist.length > 1) setTimeout(() => nextSong(), 1000);
else showErrorMessage(i18n(Key.musicNoSongsAvailable));
}
function handleLoadStart() {}
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;
}
let isVolumeDragging = $state(false);
let isMouseDown = $state(false);
let volumeBarRect: DOMRect | null = $state(null);
let rafId: number | null = $state(null);
function startVolumeDrag(event: MouseEvent) {
if (!volumeBar) return;
isMouseDown = true;
volumeBarRect = volumeBar.getBoundingClientRect();
updateVolumeLogic(event.clientX);
}
function handleVolumeMove(event: MouseEvent) {
if (!isMouseDown) return;
isVolumeDragging = true;
if (rafId) return;
rafId = requestAnimationFrame(() => {
updateVolumeLogic(event.clientX);
rafId = null;
});
}
function stopVolumeDrag() {
isMouseDown = false;
isVolumeDragging = false;
volumeBarRect = null;
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
}
}
function updateVolumeLogic(clientX: number) {
if (!audio || !volumeBar) return;
const rect = volumeBarRect || volumeBar.getBoundingClientRect();
const percent = Math.max(
0,
Math.min(1, (clientX - rect.left) / rect.width),
);
volume = percent;
audio.volume = volume;
isMuted = volume === 0;
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEYS.VOLUME, String(volume));
}
}
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;
autoplayFailed = false;
isAutoplayEnabled = false;
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEYS.USER_PAUSED, "false");
}
});
audio.addEventListener("pause", () => {
isPlaying = false;
// 注意:这里不自动设置 userPaused 为 true因为音频结束或切换也可能触发 pause。只在 togglePlay 中显式记录用户的暂停操作。)
});
audio.addEventListener("timeupdate", () => {
if (!audio) return;
currentTime = audio.currentTime;
// 每 2.1 秒保存一次进度,或者在歌曲接近结束时(虽然结束时可能不需要记忆,但为了保险)
const now = Date.now();
if (now - lastSaveTime > 2100) {
if (typeof localStorage !== 'undefined' && currentSong.id !== 0) {
localStorage.setItem(STORAGE_KEYS.LAST_SONG_PROGRESS, String(currentTime));
lastSaveTime = now;
}
}
});
audio.addEventListener("ended", () => {
if (!audio) return;
// 歌曲结束,重置保存的进度
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEYS.LAST_SONG_PROGRESS, "0");
}
// 单曲循环时,重置进度到开始
if (isRepeating === 1) {
audio.currentTime = 0;
audio.play().catch(() => {});
} else if (
isRepeating === 2 ||
isShuffled ||
(isRepeating === 0 && currentIndex < playlist.length - 1)
) {
nextSong();
} else {
isPlaying = false;
}
});
audio.addEventListener("error", (event) => {
isLoading = false;
});
audio.addEventListener("stalled", () => {});
audio.addEventListener("waiting", () => {});
}
const interactionEvents = ['click', 'keydown', 'touchstart'];
onMount(() => {
// 从缓存中读取用户偏好
if (typeof localStorage !== 'undefined') {
const userPaused = localStorage.getItem(STORAGE_KEYS.USER_PAUSED) === "true";
if (userPaused) {
isAutoplayEnabled = false;
}
const savedVolume = localStorage.getItem(STORAGE_KEYS.VOLUME);
if (savedVolume !== null) {
volume = parseFloat(savedVolume);
}
const savedShuffle = localStorage.getItem(STORAGE_KEYS.SHUFFLE);
if (savedShuffle !== null) {
isShuffled = savedShuffle === "true";
}
const savedRepeat = localStorage.getItem(STORAGE_KEYS.REPEAT);
if (savedRepeat !== null) {
isRepeating = parseInt(savedRepeat);
}
}
audio = new Audio();
audio.volume = volume;
handleAudioEvents();
interactionEvents.forEach(event => {
document.addEventListener(event, handleUserInteraction, { capture: true });
});
if (!musicPlayerConfig.enable) {
return;
}
if (mode === "meting") {
fetchMetingPlaylist();
} else {
// 使用本地播放列表不发送任何API请求
playlist = [...(musicPlayerConfig.local?.playlist ?? [])];
if (playlist.length > 0) {
setTimeout(() => {
restoreLastSong();
}, 0);
} else {
showErrorMessage(i18n(Key.musicEmptyPlaylist));
}
}
});
onDestroy(() => {
if (typeof document !== 'undefined') {
interactionEvents.forEach(event => {
document.removeEventListener(event, handleUserInteraction, { capture: true });
});
}
if (audio) {
audio.pause();
audio.src = "";
}
});
</script>
<svelte:window
onmousemove={handleVolumeMove}
onmouseup={stopVolumeDrag}
/>
{#if musicPlayerConfig.enable}
{#if showError}
<div class="music-player-error fixed bottom-20 right-4 z-60 max-w-sm onload-animation-up">
<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 shrink-0" />
<span class="text-sm flex-1">{errorMessage}</span>
<button onclick={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-101 transition-all duration-300 ease-in-out onload-animation-up flex flex-col items-end pointer-events-none"
class:expanded={!isCollapsed}
class:collapsed={isCollapsed}>
{#if showPlaylist}
<div class="playlist-panel float-panel w-80 max-h-96 overflow-hidden z-50 mb-4 pointer-events-auto"
transition:slide={{ duration: 300, axis: 'y' }}>
<div class="playlist-header flex items-center justify-between p-4 border-b border-(--line-divider)">
<h3 class="text-lg font-semibold text-90">{i18n(Key.playlist)}</h3>
<button class="btn-plain w-8 h-8 rounded-lg" onclick={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-(--btn-plain-bg-hover) cursor-pointer transition-colors"
class:bg-(--btn-plain-bg)={index === currentIndex}
class:text-(--primary)={index === currentIndex}
onclick={() => playSong(index)}
onkeydown={(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-(--primary) animate-pulse" />
{:else if index === currentIndex}
<Icon icon="material-symbols:pause" class="text-(--primary)" />
{:else}
<span class="text-sm text-(--content-meta)">{index + 1}</span>
{/if}
</div>
<!-- 歌单列表内封面仍为圆角矩形 -->
<div class="w-10 h-10 rounded-lg overflow-hidden shrink-0 shadow-sm">
<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-(--primary)={index === currentIndex} class:text-90={index !== currentIndex}>
{song.title}
</div>
<div class="text-sm text-(--content-meta) truncate" class:text-(--primary)={index === currentIndex}>
{song.artist}
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- 折叠状态的小圆球 -->
<div class="orb-player w-12 h-12 bg-(--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={!isCollapsed}
class:scale-0={!isCollapsed}
class:pointer-events-auto={isCollapsed}
class:pointer-events-none={!isCollapsed}
onclick={toggleCollapse}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleCollapse();
}
}}
role="button"
tabindex="0"
aria-label={i18n(Key.musicExpand)}>
{#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="expanded-player card-base bg-(--float-panel-bg) shadow-xl rounded-2xl p-4 transition-all duration-500 ease-in-out"
class:opacity-0={isCollapsed}
class:scale-95={isCollapsed}
class:pointer-events-auto={!isCollapsed}
class:pointer-events-none={isCollapsed}>
<div class="flex items-center gap-4 mb-4">
<div class="cover-container relative w-16 h-16 rounded-full overflow-hidden 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"
onclick={toggleMode}
title={mode === "meting" ? i18n(Key.musicSwitchToLocal) : i18n(Key.musicSwitchToMeting)}>
<Icon icon={mode === "meting" ? "material-symbols:cloud" : "material-symbols:folder"} class="text-lg" />
</button>
<button class="btn-plain w-8 h-8 rounded-lg flex items-center justify-center"
class:text-(--primary)={showPlaylist}
onclick={togglePlaylist}
title={i18n(Key.playlist)}>
<Icon icon="material-symbols:queue-music" class="text-lg" />
</button>
</div>
</div>
<div class="progress-section mb-4">
<div class="progress-bar flex-1 h-2 bg-(--btn-regular-bg) rounded-full cursor-pointer"
bind:this={progressBar}
onclick={setProgress}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const rect = progressBar?.getBoundingClientRect();
if (rect) {
const percent = 0.5;
const newTime = percent * duration;
if (audio) {
audio.currentTime = newTime;
currentTime = newTime;
}
}
}
}}
role="slider"
tabindex="0"
aria-label={i18n(Key.musicProgress)}
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={duration > 0 ? (currentTime / duration * 100) : 0}>
<div class="h-full bg-(--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}
onclick={toggleShuffle}
disabled={playlist.length <= 1}>
<Icon icon="material-symbols:shuffle" class="text-lg" />
</button>
<button class="btn-plain w-10 h-10 rounded-lg" onclick={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}
onclick={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" onclick={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}
onclick={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" onclick={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-(--btn-regular-bg) rounded-full cursor-pointer"
bind:this={volumeBar}
onmousedown={startVolumeDrag}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (e.key === 'Enter') toggleMute();
}
}}
role="slider"
tabindex="0"
aria-label={i18n(Key.musicVolume)}
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={volume * 100}>
<div class="h-full bg-(--primary) rounded-full transition-all"
class:duration-100={!isVolumeDragging}
class:duration-0={isVolumeDragging}
style="width: {volume * 100}%">
</div>
</div>
<button class="btn-plain w-8 h-8 rounded-lg flex items-center justify-center"
onclick={toggleCollapse}
title={i18n(Key.musicCollapse)}>
<Icon icon="material-symbols:expand-more" class="text-lg" />
</button>
</div>
</div>
</div>
{/if}

171
src/components/navbar.astro Normal file
View File

@@ -0,0 +1,171 @@
---
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";
import { getNavbarTransparentModeForWallpaperMode, getDefaultWallpaperMode } from "@utils/wallpaper";
import NavLinks from "@components/navbar/navLinks.astro";
import NavMenu from "@components/navbar/navMenu.svelte";
import Search from "@components/navbar/search.svelte";
import Translator from "@components/navbar/translator.svelte";
import DisplaySettings from "@components/navbar/displaySettings.svelte";
import LightDarkSwitch from "@components/navbar/lightDarkSwitch.svelte";
import WallpaperSwitch from "@components/navbar/wallpaperSwitch.svelte";
const className = Astro.props.class;
// 获取导航栏透明模式配置 - 根据当前壁纸模式读取正确的配置
const navbarTransparentMode = getNavbarTransparentModeForWallpaperMode(getDefaultWallpaperMode());
// 检查是否为首页
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-down px-3 sm:px-6 md:px-0" data-transparent-mode={navbarTransparentMode} data-is-home={isHomePage}>
<div class="absolute h-8 left-0 right-0 -top-8 bg-(--card-bg) transition"></div> <!-- used for onload animation -->
<div class:list={[
className,
"overflow-visible! max-w-(--page-width) h-18 mx-auto flex items-center justify-between md:px-4"]}>
<a href={url('/')} class="btn-plain scale-animation rounded-lg h-13 px-5 font-bold active:scale-95">
<div class="flex flex-row text-(--primary) items-center text-md">
<Icon name="material-symbols:home-pin-outline" class="text-[1.75rem] mb-1 mr-2" />
{siteConfig.title}
</div>
</a>
<div class="hidden md:flex items-center navbar-nav-links">
{links.map((l) => {
return <NavLinks link={l} />;
})}
</div>
<div class="flex items-center navbar-buttons" id="navbar-buttons">
<Search client:load></Search>
<Translator client:load></Translator>
<DisplaySettings client:load></DisplaySettings>
<LightDarkSwitch client:load></LightDarkSwitch>
<WallpaperSwitch client:load></WallpaperSwitch>
<NavMenu client:load links={links}></NavMenu>
</div>
</div>
</div>
<script>
// 为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) {
// 移除之前的滚动事件监听器(如果存在)
if (window.semifullScrollHandler) {
window.removeEventListener('scroll', window.semifullScrollHandler);
window.semifullScrollHandler = null;
}
// 设置为半透明状态
navbar.classList.add('scrolled');
return;
}
// 移除现有的scrolled类重置状态
navbar.classList.remove('scrolled');
let ticking = false;
function updateNavbarState() {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const threshold = 50; // 滚动阈值,可以根据需要调整
if (scrollTop > threshold) {
navbar?.classList.add('scrolled');
} else {
navbar?.classList.remove('scrolled');
}
ticking = false;
}
function requestTick() {
if (!ticking) {
requestAnimationFrame(updateNavbarState);
ticking = true;
}
}
// 移除之前的滚动事件监听器(如果存在)
if (window.semifullScrollHandler) {
window.removeEventListener('scroll', window.semifullScrollHandler);
}
// 保存新的事件处理器引用
window.semifullScrollHandler = requestTick;
// 监听滚动事件
window.addEventListener('scroll', requestTick, { passive: true });
// 初始化状态
updateNavbarState();
}
// 将函数暴露到全局对象,供页面切换时调用
window.initSemifullScrollDetection = initSemifullScrollDetection;
// 页面加载完成后初始化滚动检测
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSemifullScrollDetection);
} else {
initSemifullScrollDetection();
}
</script>
{import.meta.env.PROD && <script is:inline define:vars={{scriptUrl: url('/pagefind/pagefind.js')}}>
async function loadPagefind() {
try {
const response = await fetch(scriptUrl, { method: 'HEAD' });
if (!response.ok) {
throw new Error(`Pagefind script not found: ${response.status}`);
}
const pagefind = await import(scriptUrl);
await pagefind.options({
excerptLength: 20
});
window.pagefind = pagefind;
document.dispatchEvent(new CustomEvent('pagefindready'));
console.log('Pagefind loaded and initialized successfully, event dispatched.');
} catch (error) {
console.error('Failed to load Pagefind:', error);
window.pagefind = {
search: () => Promise.resolve({ results: [] }),
options: () => Promise.resolve(),
};
document.dispatchEvent(new CustomEvent('pagefindloaderror'));
console.log('Pagefind load error, event dispatched.');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadPagefind);
} else {
loadPagefind();
}
</script>}

View File

@@ -0,0 +1,139 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import { BREAKPOINT_LG } from "@constants/breakpoints";
import { getDefaultHue, getHue, setHue } from "@utils/hue";
import { onClickOutside } from "@utils/widget";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
let hue = $state(getDefaultHue());
const defaultHue = getDefaultHue();
let isOpen = $state(false);
function resetHue() {
hue = getDefaultHue();
}
function togglePanel() {
isOpen = !isOpen;
}
function openPanel() {
isOpen = true;
}
function closePanel() {
isOpen = false;
}
// 点击外部关闭面板
function handleClickOutside(event: MouseEvent) {
if (!isOpen) return;
onClickOutside(event, "display-setting", "display-settings-switch", () => {
isOpen = false;
});
}
onMount(() => {
hue = getHue();
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
});
$effect(() => {
if (hue || hue === 0) {
setHue(hue);
}
});
</script>
<div class="relative z-50" onmouseleave={closePanel}>
<button aria-label="Display Settings" class="btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90 flex items-center justify-center"
id="display-settings-switch"
onclick={() => { if (window.innerWidth < BREAKPOINT_LG) { openPanel(); } else { togglePanel(); } }}
onmouseenter={openPanel}
>
<Icon icon="material-symbols:palette-outline" class="text-[1.25rem]"></Icon>
</button>
<div id="display-setting-wrapper" class="fixed top-14.5 pt-5 right-4 w-[calc(100vw-2rem)] max-w-80 md:absolute md:top-11 md:right-0 md:w-80 md:pt-5 transition-all z-50" class:float-panel-closed={!isOpen}>
<div id="display-setting" class="card-base float-panel px-4 py-4 w-full">
<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-(--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} onclick={resetHue}>
<div class="text-(--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-(--btn-regular-bg) w-10 h-7 rounded-md flex justify-center
font-bold text-sm items-center text-(--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-sm 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>
</div>
</div>
<style lang="stylus">
#display-setting
input[type="range"]
-webkit-appearance none
height 1.5rem
background-image var(--color-selection-bar)
transition background-image 0.15s ease-in-out
/* Input Thumb */
&::-webkit-slider-thumb
-webkit-appearance none
height 1rem
width 0.5rem
border-radius 0.125rem
background rgba(255, 255, 255, 0.7)
box-shadow none
&:hover
background rgba(255, 255, 255, 0.8)
&:active
background rgba(255, 255, 255, 0.6)
&::-moz-range-thumb
-webkit-appearance none
height 1rem
width 0.5rem
border-radius 0.125rem
border-width 0
background rgba(255, 255, 255, 0.7)
box-shadow none
&:hover
background rgba(255, 255, 255, 0.8)
&:active
background rgba(255, 255, 255, 0.6)
&::-ms-thumb
-webkit-appearance none
height 1rem
width 0.5rem
border-radius 0.125rem
background rgba(255, 255, 255, 0.7)
box-shadow none
&:hover
background rgba(255, 255, 255, 0.8)
&:active
background rgba(255, 255, 255, 0.6)
</style>

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import { BREAKPOINT_LG } from "@constants/breakpoints";
import { SYSTEM_MODE, DARK_MODE, LIGHT_MODE } from "@constants/constants";
import {
getStoredTheme,
setTheme,
} from "@utils/theme";
import { onClickOutside } from "@utils/widget";
import type { LIGHT_DARK_MODE } from "@/types/config";
import { siteConfig } from "@/config";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import DropdownItem from "@/components/common/DropdownItem.svelte";
import DropdownPanel from "@/components/common/DropdownPanel.svelte";
const seq: LIGHT_DARK_MODE[] = [LIGHT_MODE, DARK_MODE, SYSTEM_MODE];
let mode: LIGHT_DARK_MODE = $state(siteConfig.defaultTheme || SYSTEM_MODE);
let isOpen = $state(false);
function switchScheme(newMode: LIGHT_DARK_MODE) {
mode = newMode;
setTheme(newMode);
}
function toggleScheme() {
let i = 0;
for (; i < seq.length; i++) {
if (seq[i] === mode) {
break;
}
}
switchScheme(seq[(i + 1) % seq.length]);
}
function openPanel() {
isOpen = true;
}
function closePanel() {
isOpen = false;
}
// 点击外部关闭面板
function handleClickOutside(event: MouseEvent) {
if (!isOpen) return;
onClickOutside(event, "light-dark-panel", "scheme-switch", () => {
isOpen = false;
});
}
onMount(() => {
mode = getStoredTheme();
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
});
</script>
<!-- z-50 make the panel higher than other float panels -->
<div class="relative z-50" role="menu" tabindex="-1" onmouseleave={closePanel}>
<button aria-label="Light/Dark/System Mode" role="menuitem" class="relative btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90" id="scheme-switch" onmouseenter={openPanel} onclick={() => { if (window.innerWidth < BREAKPOINT_LG) { openPanel(); } else { toggleScheme(); } }}>
<div class="absolute" class:opacity-0={mode !== LIGHT_MODE}>
<Icon icon="material-symbols:wb-sunny-outline-rounded" class="text-[1.25rem]"></Icon>
</div>
<div class="absolute" class:opacity-0={mode !== DARK_MODE}>
<Icon icon="material-symbols:dark-mode-outline-rounded" class="text-[1.25rem]"></Icon>
</div>
<div class="absolute" class:opacity-0={mode !== SYSTEM_MODE}>
<Icon icon="material-symbols:radio-button-partial-outline" class="text-[1.25rem]"></Icon>
</div>
</button>
<div id="light-dark-panel" class="absolute transition top-11 -right-2 pt-5" class:float-panel-closed={!isOpen}>
<DropdownPanel>
<DropdownItem
isActive={mode === LIGHT_MODE}
isLast={false}
onclick={() => switchScheme(LIGHT_MODE)}
>
<Icon icon="material-symbols:wb-sunny-outline-rounded" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.lightMode)}
</DropdownItem>
<DropdownItem
isActive={mode === DARK_MODE}
isLast={false}
onclick={() => switchScheme(DARK_MODE)}
>
<Icon icon="material-symbols:dark-mode-outline-rounded" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.darkMode)}
</DropdownItem>
<DropdownItem
isActive={mode === SYSTEM_MODE}
isLast={true}
onclick={() => switchScheme(SYSTEM_MODE)}
>
<Icon icon="material-symbols:radio-button-partial-outline" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.systemMode)}
</DropdownItem>
</DropdownPanel>
</div>
</div>

View File

@@ -0,0 +1,30 @@
---
import { Icon } from "astro-icon/components";
import { type NavbarLink } from "@/types/config";
import { url } from "@utils/url";
interface Props {
link: NavbarLink;
class?: string;
}
const { link, class: className } = Astro.props;
---
<div class:list={["nav-link-container", className]}>
<a
aria-label={link.name}
href={link.external ? link.url : url(link.url)}
target={link.external ? "_blank" : null}
class="btn-plain scale-animation rounded-lg h-11 font-bold px-3 active:scale-95 nav-link-item"
data-link-name={link.name}
>
<div class="flex items-center">
{link.icon && <Icon name={link.icon} class="text-[1.1rem] nav-link-icon" />}
<span class="nav-link-text ml-2 hidden lg:inline">{link.name}</span>
{link.external && <Icon name="fa6-solid:arrow-up-right-from-square" class="text-[0.875rem] transition -translate-y-px ml-1 text-black/20 dark:text-white/20" />}
</div>
</a>
</div>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import type { NavbarLink } from "@/types/config";
import { url } from "@utils/url";
import { onClickOutside } from "@utils/widget";
interface Props {
links: NavbarLink[];
}
let { links }: Props = $props();
let isOpen = $state(false);
function togglePanel() {
isOpen = !isOpen;
}
// 点击外部关闭面板
function handleClickOutside(event: MouseEvent) {
if (!isOpen) return;
onClickOutside(event, "nav-menu-panel", "nav-menu-switch", () => {
isOpen = false;
});
}
onMount(() => {
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
});
</script>
<div class="relative md:hidden">
<button aria-label="Menu" name="Nav Menu" class="btn-plain scale-animation rounded-lg w-11 h-11 active:scale-90"
id="nav-menu-switch"
onclick={togglePanel}
>
<Icon icon="material-symbols:menu-rounded" class="text-[1.25rem]"></Icon>
</button>
<div id="nav-menu-panel"
class="float-panel fixed transition-all right-4 px-2 py-2 max-h-[80vh] overflow-y-auto"
class:float-panel-closed={!isOpen}
>
{#each links as link}
<div class="mobile-menu-item">
<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-(--btn-plain-bg-hover) active:bg-(--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-(--primary) group-active:text-(--primary)">
{#if link.icon}
<Icon icon={link.icon} class="text-[1.1rem] mr-2" />
{/if}
{link.name}
</div>
{#if !link.external}
<Icon icon="material-symbols:chevron-right-rounded" class="transition text-[1.25rem] text-(--primary)" />
{:else}
<Icon icon="fa6-solid:arrow-up-right-from-square" class="transition text-[0.75rem] text-black/25 dark:text-white/25 -translate-x-1" />
{/if}
</a>
</div>
{/each}
</div>
</div>

View File

@@ -0,0 +1,285 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import Icon from "@iconify/svelte";
import type { SearchResult } from "@/global";
import { url } from "@utils/url";
import { navigateToPage } from "@utils/navigation";
import { onClickOutside } from "@utils/widget";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import DropdownPanel from "@/components/common/DropdownPanel.svelte";
let keywordDesktop = $state("");
let keywordMobile = $state("");
let result: SearchResult[] = $state([]);
let isSearching = $state(false);
let pagefindLoaded = false;
let initialized = $state(false);
let isDesktopSearchExpanded = $state(false);
let debounceTimer: NodeJS.Timeout;
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 = () => {
const panel = document.getElementById("search-panel");
panel?.classList.toggle("float-panel-closed");
};
const toggleDesktopSearch = () => {
isDesktopSearchExpanded = !isDesktopSearchExpanded;
if (isDesktopSearchExpanded) {
setTimeout(() => {
const input = document.getElementById("search-input-desktop") as HTMLInputElement;
input?.focus();
}, 0);
}
};
const collapseDesktopSearch = () => {
if (!keywordDesktop) {
isDesktopSearchExpanded = false;
}
};
const handleBlur = () => {
// 延迟处理以允许搜索结果的点击事件先于折叠逻辑执行
setTimeout(() => {
isDesktopSearchExpanded = false;
// 仅隐藏面板并折叠,保留搜索关键词和结果以便下次展开时查看
setPanelVisibility(false, true);
}, 200);
};
const setPanelVisibility = (show: boolean, isDesktop: boolean): void => {
const panel = document.getElementById("search-panel");
if (!panel || !isDesktop) return;
if (show) {
panel.classList.remove("float-panel-closed");
} else {
panel.classList.add("float-panel-closed");
}
};
const closeSearchPanel = (): void => {
const panel = document.getElementById("search-panel");
if (panel) {
panel.classList.add("float-panel-closed");
}
// 清空搜索关键词和结果
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;
}
};
const handleClickOutside = (event: MouseEvent) => {
const panel = document.getElementById("search-panel");
if (!panel || panel.classList.contains("float-panel-closed")) {
return;
}
onClickOutside(event, "search-panel", ["search-switch", "search-bar"], () => {
const panel = document.getElementById("search-panel");
panel?.classList.add("float-panel-closed");
isDesktopSearchExpanded = false;
});
};
onMount(() => {
document.addEventListener("click", handleClickOutside);
const initializeSearch = () => {
initialized = true;
pagefindLoaded =
typeof window !== "undefined" &&
!!window.pagefind &&
typeof window.pagefind.search === "function";
console.log("Pagefind status on init:", pagefindLoaded);
};
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
}
});
$effect(() => {
if (initialized) {
const keyword = keywordDesktop || keywordMobile;
const isDesktop = !!keywordDesktop || isDesktopSearchExpanded;
clearTimeout(debounceTimer);
if (keyword) {
debounceTimer = setTimeout(() => {
search(keyword, isDesktop);
}, 300);
} else {
result = [];
setPanelVisibility(false, isDesktop);
}
}
});
$effect(() => {
if (typeof document !== 'undefined') {
const navbar = document.getElementById('navbar');
if (isDesktopSearchExpanded) {
navbar?.classList.add('is-searching');
} else {
navbar?.classList.remove('is-searching');
}
}
});
onDestroy(() => {
if (typeof document !== 'undefined') {
document.removeEventListener("click", handleClickOutside);
const navbar = document.getElementById('navbar');
navbar?.classList.remove('is-searching');
}
clearTimeout(debounceTimer);
});
</script>
<!-- search bar for desktop view (collapsed by default) -->
<div
id="search-bar"
class="hidden lg:flex transition-all items-center h-11 rounded-lg
{isDesktopSearchExpanded ? 'bg-black/4 hover:bg-black/6 focus-within:bg-black/6 dark:bg-white/5 dark:hover:bg-white/10 dark:focus-within:bg-white/10' : 'btn-plain scale-animation active:scale-90'}
{isDesktopSearchExpanded ? 'w-48' : 'w-11'}"
role="button"
tabindex="0"
aria-label="Search"
onmouseenter={() => {if (!isDesktopSearchExpanded) toggleDesktopSearch()}}
onmouseleave={collapseDesktopSearch}
>
<Icon icon="material-symbols:search" class="absolute text-[1.25rem] pointer-events-none {isDesktopSearchExpanded ? 'ml-3' : 'left-1/2 -translate-x-1/2'} transition my-auto {isDesktopSearchExpanded ? 'text-black/30 dark:text-white/30' : ''}"></Icon>
<input id="search-input-desktop" placeholder="{i18n(I18nKey.search)}" bind:value={keywordDesktop}
onfocus={() => {if (!isDesktopSearchExpanded) toggleDesktopSearch(); search(keywordDesktop, true)}}
onblur={handleBlur}
class="transition-all pl-10 text-sm bg-transparent outline-0
h-full {isDesktopSearchExpanded ? 'w-36' : 'w-0'} text-black/50 dark:text-white/50"
>
</div>
<!-- toggle btn for phone/tablet view -->
<button onclick={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 -->
<DropdownPanel
id="search-panel"
class="float-panel-closed absolute md:w-120 top-20 left-4 md:left-[unset] right-4 z-50 search-panel"
>
<!-- 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/4 hover:bg-black/6 focus-within:bg-black/6
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}
onclick={(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-(--btn-plain-bg-hover) active:bg-(--btn-plain-bg-active)">
<div class="transition text-90 inline-flex font-bold group-hover:text-(--primary)">
{item.meta.title}<Icon icon="fa6-solid:chevron-right" class="transition text-[0.75rem] translate-x-1 my-auto text-(--primary)"></Icon>
</div>
<div class="transition text-sm text-50">
{@html item.excerpt}
</div>
</a>
{/each}
</DropdownPanel>
<style>
input:focus {
outline: 0;
}
:global(.search-panel) {
max-height: calc(100vh - 100px);
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,153 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import Icon from "@iconify/svelte";
import { BREAKPOINT_LG } from "@constants/breakpoints";
import { getTranslateLanguageFromConfig, getSiteLanguage, setStoredLanguage, getDefaultLanguage } from "@/utils/language";
import { onClickOutside } from "@utils/widget";
import { siteConfig } from "@/config";
import { getSupportedTranslateLanguages } from "@/i18n/language";
import DropdownItem from "@/components/common/DropdownItem.svelte";
import DropdownPanel from "@/components/common/DropdownPanel.svelte";
let isOpen = $state(false);
let translatePanel: HTMLElement | undefined = $state();
let currentLanguage = $state("");
// 从统一配置动态获取支持的语言列表
const languages = getSupportedTranslateLanguages();
// 根据配置文件的语言设置获取源语言
const sourceLanguage = getTranslateLanguageFromConfig(
getDefaultLanguage(),
);
function togglePanel() {
isOpen = !isOpen;
}
function openPanel() {
isOpen = true;
}
function closePanel() {
isOpen = false;
}
async function changeLanguage(languageCode: string) {
try {
// 如果翻译脚本未加载,先加载
if (!(window as any).translateScriptLoaded && typeof (window as any).loadTranslateScript === "function") {
await (window as any).loadTranslateScript();
}
// 确认翻译脚本已加载
if (!(window as any).translate) {
console.warn("translate.js is not loaded");
return;
}
// 获取翻译实例
const translate = (window as any).translate;
// 检查是否切换回源语言
const localLang = translate.language.getLocal();
// 统一使用 changeLanguage 方法
translate.changeLanguage(languageCode);
// 如果是切换回源语言,额外执行一次 reset 以确保在不刷新的情况下也能还原
if (languageCode === localLang) {
translate.reset();
}
// 同步保存到我们的缓存中
setStoredLanguage(languageCode);
// 更新当前 UI 状态
currentLanguage = languageCode;
} catch (error) {
console.error("Failed to execute translation:", error);
}
// 关闭面板
isOpen = false;
}
// 点击外部关闭面板
function handleClickOutside(event: MouseEvent) {
if (!isOpen) return;
onClickOutside(event, "translate-panel", "translate-switch", () => {
isOpen = false;
});
}
// 组件挂载时添加事件监听和初始化默认语言
onMount(() => {
document.addEventListener("click", handleClickOutside);
// 初始化当前语言为站点语言(优先缓存)
currentLanguage = getSiteLanguage();
});
onDestroy(() => {
if (typeof document !== "undefined") {
document.removeEventListener("click", handleClickOutside);
}
});
</script>
{#if siteConfig.translate?.enable}
<div class="relative z-50" onmouseleave={closePanel}>
<!-- 翻译按钮 -->
<button
aria-label="Language Translation"
class="btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90"
id="translate-switch"
onclick={() => { if (window.innerWidth < BREAKPOINT_LG) { openPanel(); } else { togglePanel(); } }}
onmouseenter={openPanel}
>
<Icon icon="material-symbols:translate" class="text-[1.25rem] transition" />
</button>
<!-- 翻译面板 -->
<div id="translate-panel-wrapper" class="fixed top-14.5 pt-5 right-4 w-[calc(100vw-2rem)] max-w-64 md:absolute md:top-11 md:right-0 md:w-64 md:pt-5 transition-all z-50" class:float-panel-closed={!isOpen}>
<DropdownPanel
bind:element={translatePanel}
id="translate-panel"
class="p-4 w-full"
>
<div class="text-sm font-medium text-(--primary) mb-3 px-1">
选择语言 / Select Language
</div>
<div class="grid grid-cols-1 gap-2 max-h-64 overflow-y-auto">
{#each languages as lang}
<DropdownItem
isActive={currentLanguage === lang.code}
onclick={() => changeLanguage(lang.code)}
class="gap-3 p-2! h-auto!"
isLast={false}
>
<span class="text-lg transition">{lang.icon}</span>
<span class="text-sm transition grow text-left">{lang.name}</span>
{#if currentLanguage === lang.code}
<span class="ml-auto text-(--primary)"></span>
{/if}
</DropdownItem>
{/each}
</div>
</DropdownPanel>
</div>
</div>
{/if}
<style>
/* 滚动条样式 */
.overflow-y-auto::-webkit-scrollbar {
width: 4px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background: var(--scrollbar-bg);
border-radius: 2px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-bg-hover);
}
</style>

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import { BREAKPOINT_LG } from "@/constants/breakpoints";
import { WALLPAPER_FULLSCREEN, WALLPAPER_BANNER, WALLPAPER_NONE } from "@constants/constants";
import {
getStoredWallpaperMode,
setWallpaperMode,
} from "@utils/wallpaper";
import { onClickOutside } from "@utils/widget";
import type { WALLPAPER_MODE } from "@/types/config";
import { siteConfig } from "@/config";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import DropdownItem from "@/components/common/DropdownItem.svelte";
import DropdownPanel from "@/components/common/DropdownPanel.svelte";
const seq: WALLPAPER_MODE[] = [WALLPAPER_BANNER, WALLPAPER_FULLSCREEN, WALLPAPER_NONE];
let mode: WALLPAPER_MODE = $state(siteConfig.wallpaper.mode || WALLPAPER_BANNER);
let isOpen = $state(false);
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]);
}
function openPanel() {
isOpen = true;
}
function closePanel() {
isOpen = false;
}
// 点击外部关闭面板
function handleClickOutside(event: MouseEvent) {
if (!isOpen) return;
onClickOutside(event, "wallpaper-mode-panel", "wallpaper-mode-switch", () => {
isOpen = false;
});
}
onMount(() => {
mode = getStoredWallpaperMode();
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
});
</script>
<!-- z-50 make the panel higher than other float panels -->
<div class="relative z-50" role="menu" tabindex="-1" onmouseleave={closePanel}>
<button aria-label="Wallpaper Mode" role="menuitem" class="relative btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90" id="wallpaper-mode-switch" onmouseenter={openPanel} onclick={() => { if (window.innerWidth < BREAKPOINT_LG) { openPanel(); } else { toggleWallpaperMode(); } }}>
<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 top-11 -right-2 pt-5" class:float-panel-closed={!isOpen}>
<DropdownPanel>
<DropdownItem
isActive={mode === WALLPAPER_BANNER}
isLast={false}
onclick={() => switchWallpaperMode(WALLPAPER_BANNER)}
>
<Icon icon="material-symbols:image-outline" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.wallpaperBanner)}
</DropdownItem>
<DropdownItem
isActive={mode === WALLPAPER_FULLSCREEN}
isLast={false}
onclick={() => switchWallpaperMode(WALLPAPER_FULLSCREEN)}
>
<Icon icon="material-symbols:wallpaper" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.wallpaperFullscreen)}
</DropdownItem>
<DropdownItem
isActive={mode === WALLPAPER_NONE}
isLast={true}
onclick={() => switchWallpaperMode(WALLPAPER_NONE)}
>
<Icon icon="material-symbols:hide-image-outline" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.wallpaperNone)}
</DropdownItem>
</DropdownPanel>
</div>
</div>

View File

@@ -0,0 +1,86 @@
---
import type { Page } from "astro";
import { Icon } from "astro-icon/components";
import { url } from "@utils/url";
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-(--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-(--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-(--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-(--primary) w-11 h-11",
{"disabled": page.url.next == undefined}
]}
>
<Icon name="material-symbols:chevron-right-rounded" class="text-[1.75rem]"></Icon>
</a>
</div>

110
src/components/pio.svelte Normal file
View File

@@ -0,0 +1,110 @@
<script lang="ts">
import { onMount, onDestroy } 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 = $state<any>(null);
let pioInitialized = $state(false);
let pioContainer = $state<HTMLElement>();
let pioCanvas = $state<HTMLCanvasElement>();
// 样式已通过 base.astro 静态引入,无需动态加载
// 等待 DOM 加载完成后再初始化 Pio
function initPio() {
if (typeof window !== "undefined" && typeof (window as any).Paul_Pio !== "undefined") {
try {
// 确保DOM元素存在
if (pioContainer && pioCanvas && !pioInitialized) {
const Paul_Pio = (window as any).Paul_Pio;
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);
}
}
// 加载必要的脚本
function loadPioAssets() {
if (typeof window === "undefined") return;
// 样式已通过 base.astro 静态引入
// 加载JS脚本
const loadScript = (src: string, id: string) => {
return new Promise<void>((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);
});
}
// 样式已通过 base.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'} onload-animation-up`} bind:this={pioContainer}>
<div class="pio-action"></div>
<canvas
id="pio"
bind:this={pioCanvas}
width={pioConfig.width || 280}
height={pioConfig.height || 250}
></canvas>
</div>
{/if}
<style>
/* Pio 相关样式将通过外部CSS文件加载 */
</style>

View File

@@ -0,0 +1,28 @@
---
import type { CollectionEntry } from "astro:content";
import { getPostUrl } from "@utils/url";
import { postConfig } from "@/config";
import Twikoo from "./twikoo.astro";
interface Props {
post: CollectionEntry<"posts">;
}
const { post } = Astro.props;
const path = getPostUrl(post);
let commentService = "";
if (postConfig.comment?.enable && postConfig.comment?.twikoo) {
commentService = "twikoo";
}
---
{postConfig.comment?.enable && (
<div class="card-base p-6 mb-4">
{commentService === 'twikoo' && <Twikoo path={path} />}
{commentService === '' && null}
</div>
)}

View File

@@ -0,0 +1,53 @@
---
import { Icon } from "astro-icon/components";
import { getPostUrlBySlug } from "@utils/url";
import { postConfig, profileConfig } from "@/config";
import { formatDateToYYYYMMDD } from "@utils/date";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
interface Props {
title: string;
id: string;
pubDate: Date;
class: string;
author: string;
sourceLink: string;
licenseName: string;
licenseUrl: string;
postUrl?: string;
}
const { title, id, pubDate, author, sourceLink, licenseName, licenseUrl, postUrl: propPostUrl } =
Astro.props;
const className = Astro.props.class;
const profileConf = profileConfig;
const licenseConf = postConfig.license;
const postUrl = sourceLink || propPostUrl || getPostUrlBySlug(id);
---
<div class={`relative transition overflow-hidden bg-(--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-(--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-(--primary) line-clamp-2">{licenseName || licenseConf.name}</a>
</div>
</div>
<Icon name="fa6-brands:creative-commons" class="transition text-[15rem] absolute pointer-events-none right-6 top-1/2 -translate-y-1/2 text-black/5 dark:text-white/5"></Icon>
</div>

View File

@@ -0,0 +1,109 @@
---
import { Icon } from "astro-icon/components";
import { render } from "astro:content";
import type { CollectionEntry } from "astro:content";
import { getFileDirFromPath, getPostUrl } from "@utils/url";
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import ImageWrapper from "@components/common/imageWrapper.astro";
import PostMetadata from "./postMeta.astro";
interface Props {
class?: string;
entry: CollectionEntry<"posts">;
style?: string;
}
const className = Astro.props.class;
const { entry, style } = Astro.props;
const url = getPostUrl(entry);
const {
title,
published,
updated,
description,
cover,
tags,
category,
pinned,
} = entry.data;
const hasCover = cover !== undefined && cover !== null && cover !== "";
const coverWidth = "28%";
const { remarkPluginFrontmatter } = await render(entry);
// derive image base path from the real file path to preserve directory casing
const imageBaseDir = getFileDirFromPath(entry.filePath || "");
---
<div class:list={["card-base flex flex-col-reverse md:flex-col w-full rounded-(--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-(--primary) dark:hover:text-(--primary)
active:text-(--title-active) dark:active:text-(--title-active)
before:w-1 before:h-5 before:rounded-md before:bg-(--primary)
before:absolute before:top-[35px] before:left-[18px] before:hidden md:before:block
">
{pinned && <Icon name="mdi:pin" class="inline text-(--primary) text-2xl mr-2 -translate-y-0.5"></Icon>}
{title}
<Icon class="inline text-[2rem] text-(--primary) md:hidden translate-y-0.5 absolute" name="material-symbols:chevron-right-rounded" ></Icon>
<Icon class="text-(--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 -->
<PostMetadata published={published} updated={updated} tags={tags} category={category || undefined} hideTagsForMobile={true} hideUpdateDate={true} postUrl={url} className="mb-4"></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>
<!-- word count and read time -->
<div class="text-sm text-black/30 dark:text-white/30 flex gap-4 transition">
<div>
{remarkPluginFrontmatter.words} {" " + i18n(remarkPluginFrontmatter.words === 1 ? I18nKey.wordCount : I18nKey.wordsCount)}
</div>
<div>|</div>
<div>
{remarkPluginFrontmatter.minutes} {" " + i18n(remarkPluginFrontmatter.minutes === 1 ? I18nKey.minuteCount : I18nKey.minutesCount)}
</div>
</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-(--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={cover} basePath={imageBaseDir} 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-13
absolute right-3 top-3 bottom-3 rounded-xl bg-(--enter-btn-bg)
hover:bg-(--enter-btn-bg-hover) active:bg-(--enter-btn-bg-active) active:scale-95
">
<Icon name="material-symbols:chevron-right-rounded"
class="transition text-(--primary) text-4xl mx-auto">
</Icon>
</a>
}
</div>
<style define:vars={{coverWidth}}>
</style>

View File

@@ -0,0 +1,163 @@
---
import { Icon } from "astro-icon/components";
import { getCategoryUrl, getTagUrl, url } from "@utils/url";
import { formatDateToYYYYMMDD } from "@utils/date";
import { umamiConfig } from '@/config';
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
export interface Props {
published: Date;
updated?: Date;
category?: string | null;
tags?: string[];
hideUpdateDate?: boolean;
hideTagsForMobile?: boolean;
isHome?: boolean;
className?: string;
slug?: string;
postUrl?: string;
}
const {
published,
updated,
category,
tags,
hideUpdateDate,
hideTagsForMobile,
isHome,
className = "",
slug,
postUrl
} = Astro.props;
const finalPostUrl = postUrl || (slug ? url(`/posts/${slug}/`) : "");
// 解析 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:list={["flex flex-wrap text-neutral-500 dark:text-neutral-400 items-center 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 || null)} aria-label={`View all posts in the ${category} category`}
class="link-lg transition text-50 text-sm font-medium
hover:text-(--primary) dark:hover:text-(--primary) whitespace-nowrap">
{category || i18n(I18nKey.uncategorized)}
</a>
</div>
</div>
<!-- tags -->
<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-(--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-(--primary) dark:hover:text-(--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 && finalPostUrl && (
<div class="flex items-center page-views-container"
data-page-url={finalPostUrl}
data-umami-base-url={umamiBaseUrl}
data-umami-api-key={umamiApiKey}
data-umami-website-id={umamiWebsiteId}
data-i18n-views={i18n(I18nKey.pageViews)}
data-i18n-visitors={i18n(I18nKey.visitors)}
data-i18n-error={i18n(I18nKey.statsError)}
>
<div class="meta-icon">
<Icon name="material-symbols:visibility-outline-rounded" class="text-xl"></Icon>
</div>
<span class="text-50 text-sm font-medium page-views-display">{i18n(I18nKey.statsLoading)}</span>
</div>
)}
</div>
<!-- 只有在非首页且启用umami且有finalPostUrl时才加载脚本 -->
{!isHome && umamiEnabled && finalPostUrl && (
<script>
import "@/plugins/umami-share";
// 客户端统计文案生成函数
function generateStatsText(pageViews, visitors, i18n_views, i18n_visitors) {
return `${i18n_views} ${pageViews} · ${i18n_visitors} ${visitors}`;
}
// 获取访问量统计
async function fetchPageViews() {
const containers = document.querySelectorAll('.page-views-container');
containers.forEach(async (containerElement) => {
const container = containerElement;
if (container.hasAttribute('data-processed')) return;
container.setAttribute('data-processed', 'true');
const pageUrl = container.getAttribute('data-page-url');
const umamiBaseUrl = container.getAttribute('data-umami-base-url');
const umamiApiKey = container.getAttribute('data-umami-api-key');
const umamiWebsiteId = container.getAttribute('data-umami-website-id');
const i18n_views = container.getAttribute('data-i18n-views');
const i18n_visitors = container.getAttribute('data-i18n-visitors');
const i18n_error = container.getAttribute('data-i18n-error');
try {
// 动态加载 umami-share 插件
// 调用全局工具获取特定页面的 Umami 统计数据
// @ts-ignore
const stats = await window.getUmamiPageStats(umamiBaseUrl, umamiApiKey, umamiWebsiteId, pageUrl);
// 从返回的数据中提取页面浏览量和访客数
const pageViews = stats.pageviews || 0;
const visitors = stats.visitors || 0;
// 更新页面上的显示内容
const displayElement = container.querySelector('.page-views-display');
if (displayElement) {
displayElement.textContent = generateStatsText(pageViews, visitors, i18n_views, i18n_visitors);
}
} catch (error) {
console.error('Error fetching page views:', error);
const displayElement = container.querySelector('.page-views-display');
if (displayElement) {
displayElement.textContent = i18n_error || null;
}
}
});
}
// 页面加载完成后获取统计数据
fetchPageViews();
document.addEventListener('astro:page-load', fetchPageViews);
</script>
)}

View File

@@ -0,0 +1,98 @@
---
import { postConfig } from "@/config";
interface Props {
path: string;
}
const config = {
...postConfig.comment.twikoo,
el: "#tcomment",
path: Astro.props.path,
};
---
<div id="tcomment"></div>
<script>
import "@/plugins/twikoo-scroll-protection";
</script>
<script is:inline define:vars={{ config }}>
// 动态创建配置对象
function createTwikooConfig() {
return {
...config,
el: '#tcomment'
};
}
// 加载 Twikoo 脚本
function loadTwikooScript() {
return new Promise((resolve, reject) => {
if (typeof twikoo !== 'undefined') {
resolve();
return;
}
const script = document.createElement('script');
script.src = '/assets/js/twikoo.all.min.js';
script.async = true;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// 初始化 Twikoo
async function initTwikoo() {
const commentEl = document.getElementById('tcomment');
if (!commentEl) return;
try {
await loadTwikooScript();
if (typeof twikoo !== 'undefined') {
commentEl.innerHTML = '';
const dynamicConfig = createTwikooConfig();
console.log('[Twikoo] 初始化配置:', dynamicConfig);
await twikoo.init(dynamicConfig);
console.log('[Twikoo] 初始化完成');
}
} catch (error) {
console.error('[Twikoo] 加载或初始化失败:', error);
}
}
// 使用 Intersection Observer 延迟加载
function setupLazyLoad() {
const commentEl = document.getElementById('tcomment');
if (!commentEl) return;
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
initTwikoo();
observer.disconnect();
}
}, { rootMargin: '200px' }); // 提前 200px 开始加载
observer.observe(commentEl);
}
// 页面加载或切换时设置延迟加载
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupLazyLoad);
} else {
setupLazyLoad();
}
// Swup 页面切换后重新设置
if (window.swup && window.swup.hooks) {
window.swup.hooks.on('content:replace', setupLazyLoad);
} else {
document.addEventListener('swup:enable', function() {
if (window.swup && window.swup.hooks) {
window.swup.hooks.on('content:replace', setupLazyLoad);
}
});
}
// 自定义事件监听
document.addEventListener('twilight:page:loaded', setupLazyLoad);
</script>

View File

@@ -0,0 +1,21 @@
---
import type { CollectionEntry } from "astro:content";
import PostCard from "@components/post/postCard.astro";
const { page } = Astro.props;
let delay = 0;
const interval = 30;
---
<div class="transition flex flex-col gap-3 py-1 md:py-0 md:gap-4 mb-4">
{page.data.map((entry: CollectionEntry<"posts">) => (
<PostCard
entry={entry}
class:list="onload-animation-up"
style={`animation-delay: ${delay++ * interval}ms;`}
></PostCard>
))}
</div>

View File

@@ -0,0 +1,83 @@
---
import type { MarkdownHeading } from "astro";
import { widgetManager } from "@utils/widget";
import Profile from "@components/sidebar/profile.astro";
import Announcement from "@components/sidebar/announcement.astro";
import Categories from "@components/sidebar/categories.astro";
import Tags from "@components/sidebar/tags.astro";
import Statistics from "@components/sidebar/statistics.astro";
import TOC from "@components/sidebar/toc.astro";
interface Props {
id?: string;
class?: string;
headings?: MarkdownHeading[];
side: "left" | "right" | "middle";
}
const { id, class: className, headings, side } = Astro.props;
// 获取配置的组件列表(按侧边栏和位置划分)
const topComponents = widgetManager.getComponentsBySideAndPosition(side, "top");
const stickyComponents = widgetManager.getComponentsBySideAndPosition(side, "sticky");
// 组件映射表
const componentMap = {
profile: Profile,
announcement: Announcement,
categories: Categories,
tags: Tags,
statistics: Statistics,
toc: TOC,
};
// 渲染组件的辅助函数
function renderComponent(component: any, index: number) {
const ComponentToRender =
componentMap[component.type as keyof typeof componentMap];
if (!ComponentToRender) return null;
const componentClass = widgetManager.getComponentClass(component, index, side);
const componentStyle = widgetManager.getComponentStyle(component, index);
return {
Component: ComponentToRender,
props: {
class: componentClass,
style: componentStyle,
headings: component.type === "toc" ? headings : undefined,
...component.customProps,
},
};
}
---
<div id={id || "sidebar"} data-side={side} class:list={[className, "w-full h-full"]}>
<!-- 顶部固定组件区域 -->
{topComponents.length > 0 && (
<div class:list={["flex flex-col w-full gap-4", { "mb-4": stickyComponents.length > 0 }]}>
{topComponents.map((component, index) => {
const renderData = renderComponent(component, index);
if (!renderData) return null;
const { Component, props } = renderData;
return <Component {...props} side={side} />;
})}
</div>
)}
<!-- 粘性组件区域 -->
{stickyComponents.length > 0 && (
<div id="sidebar-sticky" class="flex flex-col w-full gap-4 sticky top-4">
{stickyComponents.map((component, index) => {
const renderData = renderComponent(component, index);
if (!renderData) return null;
const { Component, props } = renderData;
return <Component {...props} side={side} />;
})}
</div>
)}
</div>

View File

@@ -0,0 +1,79 @@
---
import { Icon } from "astro-icon/components";
import { announcementConfig } from "@/config";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import WidgetLayout from "./widgetLayout.astro";
const config = announcementConfig;
interface Props {
class?: string;
style?: string;
}
const className = Astro.props.class;
const style = Astro.props.style;
---
<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() {
// Find all announcement widgets
const widgetLayouts = document.querySelectorAll('widget-layout[data-id^="announcement"]');
widgetLayouts.forEach(layout => {
(layout as HTMLElement).style.display = 'none';
});
localStorage.setItem('announcementClosed', 'true');
}
// Check if closed on page load
document.addEventListener('DOMContentLoaded', function() {
if (localStorage.getItem('announcementClosed') === 'true') {
const widgetLayouts = document.querySelectorAll('widget-layout[data-id^="announcement"]');
widgetLayouts.forEach(layout => {
(layout as HTMLElement).style.display = 'none';
});
}
});
// Make function global
window.closeAnnouncement = closeAnnouncement;
</script>

View File

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

View File

@@ -0,0 +1,112 @@
---
import { Icon } from "astro-icon/components";
import { profileConfig, umamiConfig } from "@/config";
import { url } from "@utils/url";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import ImageWrapper from "@/components/common/imageWrapper.astro";
// 解析 umami
const umamiEnabled = umamiConfig.enabled || false;
const umamiWebsiteId = umamiConfig.scripts.match(/data-website-id="([^"]+)"/)?.[1] || "";
const umamiApiKey = umamiConfig.apiKey || "";
const umamiBaseUrl = umamiConfig.baseUrl || "";
interface Props {
class?: string;
style?: string;
side?: string;
}
const { class: className, style, side = "default" } = Astro.props;
const id = `profile-${side}`;
---
<div id={id} data-swup-persist={id} 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-48 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" loading="eager"></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-(--primary) mx-auto rounded-full mb-2 transition"></div>
<div class="text-center text-neutral-400 mb-2.5 transition">{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"
id="site-stats-container"
data-umami-base-url={umamiBaseUrl}
data-umami-api-key={umamiApiKey}
data-umami-website-id={umamiWebsiteId}
data-i18n-error={i18n(I18nKey.statsError)}
>
<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>
import "@/plugins/umami-share";
// 客户端统计文案生成函数
function generateStatsText(pageViews, visits) {
return `浏览量 ${pageViews} · 访问次数 ${visits}`;
}
// 获取访问量统计
async function fetchSiteStats() {
const container = document.getElementById('site-stats-container');
if (!container) return;
const umamiBaseUrl = container.dataset.umamiBaseUrl;
const umamiApiKey = container.dataset.umamiApiKey;
const umamiWebsiteId = container.dataset.umamiWebsiteId;
const i18nError = container.dataset.i18nError;
try {
// 调用全局工具获取 Umami 统计数据
// @ts-ignore
const stats = await window.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 = i18nError || null;
}
}
}
// 页面加载完成后获取统计数据
fetchSiteStats();
document.addEventListener('astro:page-load', fetchSiteStats);
</script>
)}

View File

@@ -0,0 +1,19 @@
---
import { getCategoryList, getSortedPostsList, getTagList } from "@utils/content";
import StatsCharts from "./statistics.svelte";
const posts = await getSortedPostsList();
const categories = await getCategoryList();
const tags = await getTagList();
interface Props {
class?: string;
style?: string;
side?: string;
}
const { class: className, style, side } = Astro.props;
---
<StatsCharts client:load {posts} {categories} {tags} class={className} style={style} {side} />

View File

@@ -0,0 +1,572 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from "@iconify/svelte";
import dayjs from 'dayjs';
import { BREAKPOINT_LG } from "@constants/breakpoints";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
let {
posts = [],
categories = [],
tags = [],
class: className = "",
style = "",
side = "default",
}: {
posts?: any[],
categories?: any[],
tags?: any[],
class?: string,
style?: string,
side?: string,
} = $props();
const labels = {
year: i18n(I18nKey.year),
month: i18n(I18nKey.month),
day: i18n(I18nKey.day),
posts: i18n(I18nKey.posts),
activities: "Activities",
categories: i18n(I18nKey.categories),
tags: i18n(I18nKey.tags),
statistics: i18n(I18nKey.statistics),
};
let container = $state<HTMLDivElement>();
let heatmapContainer = $state<HTMLDivElement>();
let categoriesContainer = $state<HTMLDivElement>();
let tagsContainer = $state<HTMLDivElement>();
let echarts: any = $state();
let heatmapChart: any = $state();
let categoriesChart: any = $state();
let tagsChart: any = $state();
let timeScale: 'year' | 'month' | 'day' = $state('year');
let lastScale = $state<'year' | 'month' | 'day'>('year');
let isDark = $state(false);
let isDesktop = $state(true);
const updateIsDesktop = () => {
if (typeof window !== 'undefined') {
isDesktop = window.innerWidth >= BREAKPOINT_LG;
}
};
const getThemeColors = () => {
const isDarkNow = document.documentElement.classList.contains('dark');
return {
text: isDarkNow ? '#e5e7eb' : '#374151',
primary: isDarkNow ? '#60a5fa' : '#3b82f6', // Use standard hex for primary to avoid oklch issues in ECharts
grid: isDarkNow ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
areaStart: isDarkNow ? 'rgba(96, 165, 250, 0.5)' : 'rgba(59, 130, 246, 0.5)',
areaEnd: isDarkNow ? 'rgba(96, 165, 250, 0)' : 'rgba(59, 130, 246, 0)',
};
};
const getChartsFontFamily = () => {
const fallback = "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif";
if (typeof window === 'undefined') return fallback;
const target = container ?? document.body ?? document.documentElement;
const fontFamily = window.getComputedStyle(target).fontFamily;
return fontFamily && fontFamily !== 'inherit' ? fontFamily : fallback;
};
const loadECharts = async () => {
if (typeof window === 'undefined') return;
isDark = document.documentElement.classList.contains('dark');
// 动态导入 ECharts 及其组件,启用 Tree Shaking
const echartsCore = await import('echarts/core');
const { LineChart, RadarChart } = await import('echarts/charts');
const {
TitleComponent,
TooltipComponent,
GridComponent,
LegendComponent
} = await import('echarts/components');
const { SVGRenderer } = await import('echarts/renderers');
// 注册组件
echartsCore.use([
LineChart,
RadarChart,
TitleComponent,
TooltipComponent,
GridComponent,
LegendComponent,
SVGRenderer
]);
echarts = echartsCore;
};
let isInitialized = $state(false);
const initCharts = () => {
if (isInitialized) return;
initActivityChart();
if (isDesktop) initRadarCharts();
isInitialized = true;
};
const initActivityChart = (isUpdate = false) => {
if (!heatmapContainer || !echarts) return;
// 尝试获取现有实例以支持 Swup 持久化
const existingChart = echarts.getInstanceByDom(heatmapContainer);
const isNew = !existingChart;
if (existingChart) {
heatmapChart = existingChart;
} else {
heatmapChart = echarts.init(heatmapContainer, isDark ? 'dark' : null, { renderer: 'svg' });
}
const colors = getThemeColors();
const fontFamily = getChartsFontFamily();
const now = dayjs();
let data: any[] = [];
let xAxisData: string[] = [];
if (timeScale === 'year') {
// Show from the oldest post's year to current year, at least 5 years
const oldestYear = posts.length > 0
? Math.min(...posts.map(p => dayjs(p.data.published).year()))
: now.year();
const currentYear = now.year();
const startYear = Math.min(oldestYear, currentYear - 4);
for (let year = startYear; year <= currentYear; year++) {
const yearStr = year.toString();
xAxisData.push(yearStr);
const count = posts.filter(p => dayjs(p.data.published).year() === year).length;
data.push(count);
}
} else if (timeScale === 'month') {
// Last 12 months
for (let i = 11; i >= 0; i--) {
const month = now.subtract(i, 'month');
const monthStr = month.format('YYYY-MM');
xAxisData.push(month.format('MMM'));
const count = posts.filter(p => dayjs(p.data.published).format('YYYY-MM') === monthStr).length;
data.push(count);
}
} else {
// Last 30 days
for (let i = 29; i >= 0; i--) {
const day = now.subtract(i, 'day');
const dayStr = day.format('YYYY-MM-DD');
xAxisData.push(day.format('DD'));
const count = posts.filter(p => dayjs(p.data.published).format('YYYY-MM-DD') === dayStr).length;
data.push(count);
}
}
const option = {
backgroundColor: 'transparent',
textStyle: { fontFamily },
animation: isNew || isUpdate,
animationDuration: isNew ? 2000 : 500,
animationEasing: 'cubicOut',
title: {
text: labels.activities,
left: 'left',
textStyle: { fontFamily, fontSize: 14, color: colors.text, fontWeight: 'bold' }
},
tooltip: {
trigger: 'axis',
confine: true,
formatter: (params: any) => `${params[0].name}: ${params[0].value} ${labels.posts}`
},
grid: { left: '10%', right: '5%', bottom: '15%', top: '25%', containLabel: true },
xAxis: {
type: 'category',
data: xAxisData,
axisLine: { lineStyle: { color: colors.grid } },
axisLabel: { fontFamily, color: colors.text, fontSize: 10 }
},
yAxis: {
type: 'value',
minInterval: 1,
axisLine: { show: false },
axisLabel: { fontFamily, color: colors.text, fontSize: 10 },
splitLine: { lineStyle: { color: colors.grid, type: 'dashed' } }
},
series: [{
data: data,
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
itemStyle: { color: colors.primary },
lineStyle: { width: 3, color: colors.primary },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: colors.areaStart },
{ offset: 1, color: colors.areaEnd }
])
}
}]
};
heatmapChart.setOption(option);
};
const initRadarCharts = () => {
if (!echarts) return;
const colors = getThemeColors();
const fontFamily = getChartsFontFamily();
// Categories Radar
if (categoriesContainer) {
const existingCategoriesChart = echarts.getInstanceByDom(categoriesContainer);
if (existingCategoriesChart) {
categoriesChart = existingCategoriesChart;
} else {
categoriesChart = echarts.init(categoriesContainer, isDark ? 'dark' : null, { renderer: 'svg' });
}
const indicator = categories.map(c => ({ name: c.name, max: Math.max(...categories.map(x => x.count), 5) }));
const data = categories.map(c => c.count);
categoriesChart.setOption({
backgroundColor: 'transparent',
textStyle: { fontFamily },
animation: true,
animationDuration: 2000,
animationEasing: 'exponentialOut',
tooltip: {
show: true,
trigger: 'item',
confine: true
},
title: {
text: labels.categories,
left: 'left',
textStyle: { fontFamily, fontSize: 14, color: colors.text, fontWeight: 'bold' }
},
radar: {
indicator: indicator,
radius: '60%',
center: ['50%', '60%'],
axisName: { fontFamily, color: colors.text, fontSize: 10 },
splitLine: { lineStyle: { color: colors.grid } },
splitArea: { show: false }
},
series: [{
type: 'radar',
data: [{ value: data, name: labels.categories }],
areaStyle: { color: 'rgba(255, 123, 0, 0.6)' },
lineStyle: { color: 'rgba(255, 123, 0, 0.9)' },
itemStyle: { color: 'rgba(255, 123, 0, 0.9)' },
emphasis: {
areaStyle: { color: 'rgba(255, 123, 0, 0.9)' }
}
}]
});
}
// Tags Radar
if (tagsContainer) {
const existingTagsChart = echarts.getInstanceByDom(tagsContainer);
if (existingTagsChart) {
tagsChart = existingTagsChart;
} else {
tagsChart = echarts.init(tagsContainer, isDark ? 'dark' : null, { renderer: 'svg' });
}
const sortedTags = [...tags].sort((a, b) => b.count - a.count).slice(0, 8);
const indicator = sortedTags.map(t => ({ name: t.name, max: Math.max(...sortedTags.map(x => x.count), 5) }));
const data = sortedTags.map(t => t.count);
tagsChart.setOption({
backgroundColor: 'transparent',
textStyle: { fontFamily },
animation: true,
animationDuration: 2000,
animationEasing: 'exponentialOut',
tooltip: {
show: true,
trigger: 'item',
confine: true
},
title: {
text: labels.tags,
left: 'left',
textStyle: { fontFamily, fontSize: 14, color: colors.text, fontWeight: 'bold' }
},
radar: {
indicator: indicator,
radius: '60%',
center: ['50%', '60%'],
axisName: { fontFamily, color: colors.text, fontSize: 10 },
splitLine: { lineStyle: { color: colors.grid } },
splitArea: { show: false }
},
series: [{
type: 'radar',
data: [{ value: data, name: labels.tags }],
areaStyle: { color: 'rgba(16, 185, 129, 0.6)' },
lineStyle: { color: 'rgba(16, 185, 129, 0.9)' },
itemStyle: { color: 'rgba(16, 185, 129, 0.9)' },
emphasis: {
areaStyle: { color: 'rgba(16, 185, 129, 0.9)' }
}
}]
});
}
};
onMount(() => {
updateIsDesktop();
let visibilityObserver: IntersectionObserver;
const runInit = async () => {
await loadECharts();
// 检查是否处于初始加载动画阶段
const hasInitialAnimation = document.documentElement.classList.contains('show-initial-animation') ||
document.documentElement.classList.contains('is-loading');
if (hasInitialAnimation) {
// 查找带有动画类的侧边栏容器
const sidebar = container?.closest('.onload-animation-up');
const startInit = () => {
if (!isInitialized) initCharts();
};
if (sidebar) {
// 监听侧边栏淡入动画开始
sidebar.addEventListener('animationstart', (e) => {
if ((e as AnimationEvent).animationName === 'fade-in-up') {
startInit();
}
}, { once: true });
}
// 使用 MutationObserver 监听 html 的 class 变化,作为更可靠的保底机制
const htmlObserver = new MutationObserver(() => {
const isStillLoading = document.documentElement.classList.contains('is-loading');
// 一旦 loading 结束(进入动画播放阶段),就开始绘制图表
if (!isStillLoading) {
startInit();
htmlObserver.disconnect();
}
});
htmlObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
// 较长的保底时间3秒防止所有监听机制意外失效
setTimeout(() => {
startInit();
htmlObserver.disconnect();
}, 3000);
} else {
// 无动画状态,直接加载
initCharts();
}
};
if (container) {
visibilityObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
visibilityObserver.disconnect();
runInit();
}
});
visibilityObserver.observe(container);
}
const handleResize = () => {
const wasDesktop = isDesktop;
updateIsDesktop();
heatmapChart?.resize();
if (isDesktop) {
if (wasDesktop) {
categoriesChart?.resize();
tagsChart?.resize();
} else {
// 从移动端切换到桌面端,需要初始化雷达图,延迟一帧确保 DOM 已更新({#if isDesktop} 生效)
setTimeout(() => {
initRadarCharts();
}, 0);
}
}
};
const observer = new MutationObserver(() => {
const newIsDark = document.documentElement.classList.contains('dark');
if (newIsDark !== isDark) {
isDark = newIsDark;
if (isInitialized) {
initActivityChart(true);
if (isDesktop) initRadarCharts();
}
}
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
observer.disconnect();
if (visibilityObserver) visibilityObserver.disconnect();
};
});
$effect(() => {
if (timeScale && echarts && isInitialized) {
if (timeScale !== lastScale) {
lastScale = timeScale;
initActivityChart(true);
}
}
});
</script>
<div id={`statistics-${side}`} data-swup-persist={`statistics-${side}`} bind:this={container} class={"pb-4 card-base " + className} {style}>
<div class="font-bold transition text-lg text-neutral-900 dark:text-neutral-100 relative ml-8 mt-4 mb-2
before:w-1 before:h-4 before:rounded-md before:bg-(--primary)
before:absolute before:left-[-16px] before:top-[5.5px]">{labels.statistics}</div>
<div class="collapse-wrapper px-4 overflow-hidden">
<div class="stats-charts">
<div class="chart-section heatmap-section">
<div class="section-header">
<div class="dropdown-wrapper">
<button class="time-scale-select flex items-center gap-1">
{labels[timeScale]}
<span class="dropdown-icon flex items-center">
<Icon icon="material-symbols:keyboard-arrow-down-rounded" />
</span>
</button>
<div class="dropdown-menu-custom">
<button class="dropdown-item-custom" class:active={timeScale === 'year'} onclick={() => timeScale = 'year'}>{labels.year}</button>
<button class="dropdown-item-custom" class:active={timeScale === 'month'} onclick={() => timeScale = 'month'}>{labels.month}</button>
<button class="dropdown-item-custom" class:active={timeScale === 'day'} onclick={() => timeScale = 'day'}>{labels.day}</button>
</div>
</div>
</div>
<div bind:this={heatmapContainer} class="heatmap-container"></div>
</div>
{#if isDesktop}
<div class="chart-section radar-section">
<div bind:this={categoriesContainer} class="radar-container"></div>
</div>
<div class="chart-section radar-section">
<div bind:this={tagsContainer} class="radar-container"></div>
</div>
{/if}
</div>
</div>
</div>
<style>
.stats-charts {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
}
.chart-section {
width: 100%;
position: relative;
}
.heatmap-section {
position: relative;
}
.section-header {
position: absolute;
right: 0;
top: 0;
z-index: 10;
}
.time-scale-select {
display: flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.7rem;
background: var(--btn-regular-bg);
color: var(--btn-content);
border: 1px solid var(--line-color);
cursor: pointer;
opacity: 0.7;
transition: all 0.2s;
outline: none;
}
.time-scale-select:hover {
opacity: 1;
border-color: var(--primary);
}
.dropdown-wrapper {
position: relative;
display: inline-block;
}
.dropdown-wrapper:hover .dropdown-menu-custom {
opacity: 1;
visibility: visible;
translate: 0 0;
}
.dropdown-menu-custom {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: var(--card-bg);
border: 1px solid var(--line-color);
border-radius: 4px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
opacity: 0;
visibility: hidden;
translate: 0 -10px;
transition: all 0.2s;
z-index: 50;
min-width: 80px;
overflow: hidden;
}
.dropdown-item-custom {
width: 100%;
text-align: left;
padding: 6px 12px;
font-size: 0.7rem;
color: var(--btn-content);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.dropdown-item-custom:hover {
background: var(--btn-plain-bg-hover);
color: var(--primary);
}
.dropdown-item-custom.active {
color: var(--primary);
font-weight: bold;
background: var(--btn-plain-bg-hover);
}
.dropdown-icon {
font-size: 0.9rem;
transition: rotate 0.2s;
}
.dropdown-wrapper:hover .dropdown-icon {
rotate: 180deg;
}
.heatmap-container {
height: 180px;
width: 100%;
}
.radar-container {
height: 250px;
width: 100%;
}
</style>

View File

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

View File

@@ -0,0 +1,83 @@
---
import { NAVBAR_HEIGHT } from "@constants/constants";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import WidgetLayout from "./widgetLayout.astro";
interface Props {
id?: string;
class?: string;
style?: string;
depth?: number;
side?: string;
}
const { id: propId, class: className, style, depth = 3, side = "default" } = Astro.props;
const id = propId || `toc-wrapper-${side}`;
---
<WidgetLayout name={i18n(I18nKey.tableOfContents)} id={id} class:list={[className, "toc-wrapper", "toc-hide"]} style={style}>
<div class="toc-scroll-container">
<table-of-contents class="group" data-depth={depth} data-navbar-height={NAVBAR_HEIGHT}>
<div class="toc-inner-content relative"></div>
</table-of-contents>
</div>
</WidgetLayout>
<script>
import "./toc";
</script>
<style>
.toc-scroll-container {
max-height: calc(100vh - 20rem);
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
}
.toc-scroll-container::-webkit-scrollbar {
width: 4px;
}
.toc-scroll-container::-webkit-scrollbar-track {
background: transparent;
}
.toc-scroll-container::-webkit-scrollbar-thumb {
background: var(--toc-btn-hover);
border-radius: 10px;
}
table-of-contents#toc {
position: relative;
display: block;
}
/* 高亮当前项文字 */
:global(.toc-inner-content a.visible .text-50),
:global(.toc-inner-content a.visible .text-30) {
color: var(--primary);
font-weight: 500;
}
/* 基础过渡样式 */
:global(widget-layout.toc-wrapper) {
transition:
max-height 300ms cubic-bezier(0.4, 0, 0.2, 1),
opacity 300ms cubic-bezier(0.4, 0, 0.2, 1),
margin-bottom 300ms cubic-bezier(0.4, 0, 0.2, 1),
padding 300ms cubic-bezier(0.4, 0, 0.2, 1),
visibility 300ms;
max-height: 1200px; /* 足够容纳目录的高度,避免 100vh 可能带来的过长过渡延迟 */
overflow: hidden;
}
/* 隐藏状态 */
:global(widget-layout.toc-wrapper.toc-hide) {
max-height: 0;
opacity: 0;
margin-bottom: -1rem; /* 抵消父级 flex 的 gap-4 */
padding-top: 0 !important;
padding-bottom: 0 !important;
visibility: hidden;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,405 @@
import { NAVBAR_HEIGHT } from "@constants/constants";
import { widgetManager } from "@utils/widget";
export class TableOfContents extends HTMLElement {
tocEl: HTMLElement | null = null;
visibleClass = "visible";
observer: IntersectionObserver;
anchorNavTarget: HTMLElement | null = null;
headingIdxMap = new Map<string, number>();
headings: HTMLElement[] = [];
tocEntries: HTMLAnchorElement[] = [];
active: boolean[] = [];
activeIndicator: HTMLElement | null = null;
_retryCount = 0;
_backToTopObserver: MutationObserver | null = null;
_handleBtnClick = (e: Event) => {
e.stopPropagation();
const panel = this.querySelector('.toc-floating-panel');
const isHidden = panel?.classList.contains('hidden') || panel?.classList.contains('opacity-0');
this.toggleFloatingPanel(!!isHidden);
};
_handleDocClick = (e: Event) => {
const panel = this.querySelector('.toc-floating-panel');
if (panel && !panel.classList.contains('hidden') && !panel.contains(e.target as Node)) {
this.toggleFloatingPanel(false);
}
};
constructor() {
super();
this.observer = new IntersectionObserver(this.markVisibleSection);
};
markActiveHeading = (idx: number)=> {
this.active = new Array(this.headings.length).fill(false);
this.active[idx] = true;
};
isInRange(value: number, min: number, max: number) {
return min < value && value < max;
};
fallback = () => {
if (!this.headings.length) return;
let activeIdx = -1;
for (let i = 0; i < this.headings.length; i++) {
const heading = this.headings[i];
const rect = heading.getBoundingClientRect();
if (rect.top < 100) {
activeIdx = i;
} else {
break;
}
}
if (activeIdx === -1) {
activeIdx = 0;
}
this.markActiveHeading(activeIdx);
};
toggleActiveHeading = () => {
let min = this.active.length, max = -1;
for (let i = 0; i < this.active.length; i++) {
if (this.active[i]) {
this.tocEntries[i].classList.add(this.visibleClass);
min = Math.min(min, i);
max = Math.max(max, i);
} else {
this.tocEntries[i].classList.remove(this.visibleClass);
}
}
if (max === -1) {
this.activeIndicator?.setAttribute("style", `opacity: 0`);
} else {
const top = this.tocEntries[min].offsetTop;
const bottom = this.tocEntries[max].offsetTop + this.tocEntries[max].offsetHeight;
this.activeIndicator?.setAttribute("style", `top: ${top}px; height: ${bottom - top}px; opacity: 1`);
}
};
scrollToActiveHeading = () => {
if (this.anchorNavTarget || !this.tocEl) return;
const activeHeading = this.querySelectorAll<HTMLDivElement>(`.${this.visibleClass}`);
if (!activeHeading.length) return;
const topmost = activeHeading[0];
const bottommost = activeHeading[activeHeading.length - 1];
const tocHeight = this.tocEl.clientHeight;
let top;
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();
this.scrollToActiveHeading();
});
};
markVisibleSection = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
const id = entry.target.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)
this.anchorNavTarget = null;
});
if (!this.active.includes(true))
this.fallback();
this.update();
};
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 = parseInt(this.dataset.navbarHeight || NAVBAR_HEIGHT.toString());
const targetTop = targetElement.getBoundingClientRect().top + window.scrollY - 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;
}
// If floating, close the panel after click
if (this.dataset.isFloating === "true") {
this.toggleFloatingPanel(false);
}
}
};
isPostPage() {
return window.location.pathname.includes('/posts/') ||
document.querySelector('.custom-md, .markdown-content') !== null;
}
updateFloatingPosition = () => {
if (this.dataset.isFloating !== "true") return;
const container = this.querySelector('.toc-floating-container') as HTMLElement;
const backToTopBtn = document.getElementById('back-to-top-btn');
if (!container || !backToTopBtn) return;
if (backToTopBtn.classList.contains('hide')) {
container.classList.remove('move-up');
} else {
container.classList.add('move-up');
}
}
toggleFloatingPanel(show: boolean) {
const panel = this.querySelector('.toc-floating-panel');
if (!panel) return;
if (show) {
panel.classList.remove('hidden');
requestAnimationFrame(() => {
panel.classList.remove('opacity-0', 'translate-y-4', 'pointer-events-none');
});
} else {
panel.classList.add('opacity-0', 'translate-y-4', 'pointer-events-none');
setTimeout(() => {
panel.classList.add('hidden');
}, 300);
}
}
regenerateTOC() {
const isFloating = this.dataset.isFloating === "true";
const tocWrapper = isFloating
? this.querySelector('.toc-floating-container') as HTMLElement
: this.closest('widget-layout') as HTMLElement;
if (!tocWrapper) return false;
const headings = widgetManager.getPageHeadings();
if (headings.length === 0 && this.isPostPage() && this._retryCount < 3) {
this._retryCount++;
setTimeout(() => this.init(), 120);
return false;
}
this._retryCount = 0;
const isPost = this.isPostPage();
if (headings.length === 0 && !isPost) {
if (!tocWrapper.classList.contains('toc-hide')) {
if (!isFloating) {
tocWrapper.style.maxHeight = tocWrapper.offsetHeight + 'px';
tocWrapper.offsetHeight;
tocWrapper.classList.add('toc-hide');
tocWrapper.style.maxHeight = '';
} else {
tocWrapper.classList.add('toc-hide');
}
}
return true;
}
if (tocWrapper.classList.contains('toc-hide')) {
tocWrapper.classList.remove('toc-hide');
if (!isFloating) {
const targetHeight = tocWrapper.scrollHeight;
tocWrapper.style.maxHeight = '0px';
tocWrapper.offsetHeight;
tocWrapper.style.maxHeight = targetHeight + 'px';
setTimeout(() => {
if (!tocWrapper.classList.contains('toc-hide')) {
tocWrapper.style.maxHeight = '';
}
}, 300);
}
}
const minDepth = Math.min(...headings.map(h => h.depth));
const maxLevel = parseInt(this.dataset.depth || '3');
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';
const badgeContent = heading.depth === minDepth ? (heading1Count++) :
heading.depth === minDepth + 1 ? '<div class="transition w-2 h-2 rounded-[0.1875rem] bg-(--toc-badge-bg)"></div>' :
'<div class="transition w-1.5 h-1.5 rounded-xs 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-(--toc-btn-hover) active:bg-(--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-(--toc-badge-bg) text-(--btn-content)' : ''}">
${badgeContent}
</div>
<div class="transition text-sm ${heading.depth <= minDepth + 1 ? 'text-50' : 'text-30'}">${heading.text}</div>
</a>`;
}).join('');
const innerContent = this.querySelector('.toc-inner-content');
if (innerContent) {
innerContent.innerHTML = tocHTML + '<div class="active-indicator -z-10 absolute left-0 right-0 rounded-xl transition-all pointer-events-none bg-(--toc-btn-hover)" style="opacity: 0"></div>';
}
return true;
}
init() {
this.observer.disconnect();
this.headingIdxMap.clear();
this.headings = [];
this.active = [];
if (!this.regenerateTOC()) return;
this.tocEl = this.querySelector('.toc-scroll-container');
this.tocEl?.addEventListener("click", this.handleAnchorClick, { capture: true });
this.activeIndicator = this.querySelector(".active-indicator");
if (this.dataset.isFloating === "true") {
const btn = this.querySelector('.toc-floating-btn');
btn?.removeEventListener('click', this._handleBtnClick);
btn?.addEventListener('click', this._handleBtnClick);
document.removeEventListener('click', this._handleDocClick);
document.addEventListener('click', this._handleDocClick);
// 监听 backToTop 按钮的状态
const backToTopBtn = document.getElementById('back-to-top-btn');
if (backToTopBtn) {
this._backToTopObserver?.disconnect();
this._backToTopObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
this.updateFloatingPosition();
}
});
});
this._backToTopObserver.observe(backToTopBtn, { attributes: true });
this.updateFloatingPosition(); // 初始状态检查
}
}
const allEntries = Array.from(this.querySelectorAll<HTMLAnchorElement>("a[href^='#']"));
const validHeadings: HTMLElement[] = [];
const validEntries: HTMLAnchorElement[] = [];
for (let i = 0; i < allEntries.length; i++) {
const entry = allEntries[i];
const id = decodeURIComponent(entry.hash?.substring(1));
const heading = document.getElementById(id);
if (heading instanceof HTMLElement) {
validHeadings.push(heading);
validEntries.push(entry);
this.headingIdxMap.set(id, validEntries.length - 1);
}
}
this.headings = validHeadings;
this.tocEntries = validEntries;
this.active = new Array(this.tocEntries.length).fill(false);
if (this.tocEntries.length === 0) return;
this.headings.forEach((heading) => this.observer.observe(heading));
this.fallback();
this.update();
};
connectedCallback() {
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 });
setTimeout(tryInit, 300);
} else {
tryInit();
setTimeout(tryInit, 300);
}
const setupSwup = () => {
if (window.swup && window.swup.hooks) {
if ((this as any)._swupListenersAdded) return;
window.swup.hooks.on('visit:start', () => {
if (this.isPostPage()) {
const isFloating = this.dataset.isFloating === "true";
const tocWrapper = isFloating
? this.querySelector('.toc-floating-container') as HTMLElement
: this.closest('widget-layout') as HTMLElement;
if (tocWrapper && !tocWrapper.classList.contains('toc-hide')) {
if (!isFloating) {
tocWrapper.style.maxHeight = tocWrapper.offsetHeight + 'px';
tocWrapper.offsetHeight;
tocWrapper.classList.add('toc-hide');
tocWrapper.style.maxHeight = '';
} else {
tocWrapper.classList.add('toc-hide');
}
}
}
});
window.swup.hooks.on('content:replace', () => {
const isFloating = this.dataset.isFloating === "true";
const tocWrapper = isFloating
? this.querySelector('.toc-floating-container') as HTMLElement
: this.closest('widget-layout') as HTMLElement;
if (tocWrapper && !this.isPostPage()) {
tocWrapper.classList.add('toc-hide');
if (!isFloating) tocWrapper.style.maxHeight = '';
}
setTimeout(() => this.init(), 100);
});
(this as any)._swupListenersAdded = true;
}
};
if (window.swup) setupSwup();
else document.addEventListener('swup:enable', setupSwup);
window.addEventListener('content-decrypted', () => this.init());
};
disconnectedCallback() {
this.headings.forEach((heading) => this.observer.unobserve(heading));
this.observer.disconnect();
this._backToTopObserver?.disconnect();
this.tocEl?.removeEventListener("click", this.handleAnchorClick);
const btn = this.querySelector('.toc-floating-btn');
btn?.removeEventListener('click', this._handleBtnClick);
document.removeEventListener('click', this._handleDocClick);
};
}
if (!customElements.get("table-of-contents")) {
customElements.define("table-of-contents", TableOfContents);
}

View File

@@ -0,0 +1,63 @@
---
import { Icon } from "astro-icon/components";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
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
before:w-1 before:h-4 before:rounded-md before:bg-(--primary)
before:absolute before:left-[-16px] before:top-[5.5px]">{name}</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-(--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>

View File

@@ -0,0 +1,121 @@
---
import { Icon } from "astro-icon/components";
import { NAVBAR_HEIGHT } from "@constants/constants";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
interface Props {
class?: string;
style?: string;
}
const { class: className, style } = Astro.props;
---
<table-of-contents data-is-floating="true" data-navbar-height={NAVBAR_HEIGHT} class={className} style={style}>
<div class="toc-floating-container">
<div class="toc-floating-btn-wrapper rounded-2xl overflow-hidden transition">
<button class="toc-floating-btn btn-card rounded-2xl p-0 flex items-center justify-center" aria-label="Table of Contents">
<Icon name="material-symbols:format-list-bulleted-rounded" size={24} />
</button>
</div>
<div class="toc-floating-panel hidden opacity-0 translate-y-4 pointer-events-none">
<div class="toc-panel-header">
<Icon name="material-symbols:format-list-bulleted-rounded" class="text-(--primary)" size={20} />
<span class="text-sm font-bold text-75">{i18n(I18nKey.tableOfContents)}</span>
</div>
<div class="toc-inner-content relative"></div>
</div>
</div>
</table-of-contents>
<script>
import "./sidebar/toc";
</script>
<style lang="stylus">
.toc-floating-container
position: fixed
right: 1rem
bottom: 6.78rem
z-index: 90
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)
pointer-events: none
.toc-floating-btn-wrapper
width: 3rem
height: 3rem
pointer-events: auto
box-shadow: 0 0 0 1px var(--btn-regular-bg), 0 0 1em var(--btn-regular-bg)
color: var(--primary)
.toc-floating-btn
width: 100%
height: 100%
cursor: pointer
color: inherit
border: none
transition: transform 0.2s, background-color 0.2s
&:active
scale: 0.9
.toc-floating-panel
position: absolute
bottom: 100%
right: 0
margin-bottom: 1rem
width: 16rem
max-height: 60vh
overflow-y: auto
background-color: var(--card-bg)
border-radius: 1rem
padding: 0.75rem
box-shadow: var(--shadow-lg)
border: 1px solid var(--btn-regular-bg)
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
z-index: 91
pointer-events: auto
/* Scrollbar styling */
scrollbar-width: thin
scrollbar-color: var(--toc-btn-hover) transparent
.toc-panel-header
display: flex
align-items: center
gap: 0.5rem
padding: 0.25rem 0.5rem 0.75rem 0.5rem
border-bottom: 1px solid var(--btn-regular-bg)
margin-bottom: 0.5rem
.toc-floating-panel::-webkit-scrollbar
width: 4px
.toc-floating-panel::-webkit-scrollbar-track
background: transparent
.toc-floating-panel::-webkit-scrollbar-thumb
background: var(--toc-btn-hover)
border-radius: 10px
.toc-floating-container.move-up
translate: 0 -4rem
.toc-floating-container.toc-hide
translate: 0 2rem
scale: 0.9
opacity: 0
pointer-events: none
/* Mobile adjustments */
@media (max-width: 768px)
.toc-floating-container
bottom: 5.5rem
right: 0.5rem
/* Highlighting for TOC items */
:global(.toc-floating-panel .toc-inner-content a.visible .text-50),
:global(.toc-floating-panel .toc-inner-content a.visible .text-30)
color: var(--primary)
font-weight: 500
</style>

127
src/config.ts Normal file
View File

@@ -0,0 +1,127 @@
import yaml from "js-yaml";
import type {
SiteConfig,
NavbarLink,
NavbarConfig,
SidebarConfig,
ProfileConfig,
AnnouncementConfig,
PostConfig,
FooterConfig,
ParticleConfig,
MusicPlayerConfig,
PioConfig,
} from "./types/config";
import { LinkPreset } from "./types/config";
import rawConfig from "../twilight.config.yaml?raw";
type ConfigFile = {
site: SiteConfig;
umami: {
enabled: boolean;
apiKey?: string;
baseUrl: string;
scripts?: string;
};
navbar: {
links: Array<NavbarLink | LinkPreset | string>;
};
sidebar: SidebarConfig;
profile: ProfileConfig;
announcement: AnnouncementConfig;
post: PostConfig;
footer: FooterConfig;
particle: ParticleConfig;
musicPlayer: MusicPlayerConfig;
pio: PioConfig;
};
const config = yaml.load(rawConfig) as ConfigFile;
const linkPresetNameMap: Record<string, LinkPreset> = {
Home: LinkPreset.Home,
Archive: LinkPreset.Archive,
Projects: LinkPreset.Projects,
Skills: LinkPreset.Skills,
Timeline: LinkPreset.Timeline,
Diary: LinkPreset.Diary,
Albums: LinkPreset.Albums,
Anime: LinkPreset.Anime,
About: LinkPreset.About,
Friends: LinkPreset.Friends,
};
const normalizeNavbarLink = (
link: NavbarLink | LinkPreset | string,
): NavbarLink | LinkPreset => {
if (typeof link === "string") {
const preset = linkPresetNameMap[link];
if (preset === undefined) {
throw new Error(`Unknown LinkPreset: ${link}`);
}
return preset;
}
if (typeof link === "number") {
return link;
}
const children = link.children?.map(normalizeNavbarLink);
return children ? { ...link, children } : link;
};
const normalizeNavbarLinks = (links: Array<NavbarLink | LinkPreset | string>) =>
links.map(normalizeNavbarLink);
const resolvedPostConfig: PostConfig = {
...config.post,
comment: config.post.comment.twikoo
? {
...config.post.comment,
twikoo: {
...config.post.comment.twikoo,
lang: config.post.comment.twikoo.lang ?? config.site.lang,
},
}
: config.post.comment,
};
// 站点配置
export const siteConfig: SiteConfig = config.site;
// Umami统计配置
export const umamiConfig = {
enabled: config.umami.enabled,
apiKey: import.meta.env.UMAMI_API_KEY ?? config.umami.apiKey,
baseUrl: config.umami.baseUrl,
scripts: import.meta.env.UMAMI_TRACKING_CODE ?? config.umami.scripts,
} as const;
// 导航栏配置
export const navbarConfig: NavbarConfig = {
links: normalizeNavbarLinks(config.navbar.links),
};
// 侧边栏配置
export const sidebarConfig: SidebarConfig = config.sidebar;
// 资料配置
export const profileConfig: ProfileConfig = config.profile;
// 公告配置
export const announcementConfig: AnnouncementConfig = config.announcement;
// 文章配置
export const postConfig: PostConfig = resolvedPostConfig;
// 页脚配置
export const footerConfig: FooterConfig = config.footer;
// 粒子特效配置
export const particleConfig: ParticleConfig = config.particle;
// 音乐播放器配置
export const musicPlayerConfig: MusicPlayerConfig = config.musicPlayer;
// 看板娘配置
export const pioConfig: PioConfig = config.pio;

View File

@@ -0,0 +1,22 @@
/**
* Width breakpoints
* Dynamically read from src/styles/main.css @theme configuration
*/
const getBreakpoint = (name: string, fallback: number): number => {
if (typeof window === "undefined") return fallback;
// Tailwind v4 exports @theme variables as standard CSS variables
const value = getComputedStyle(document.documentElement)
.getPropertyValue(`--breakpoint-${name}`)
.trim();
// Remove 'px' unit and convert to number
const parsed = parseInt(value, 10);
return isNaN(parsed) ? fallback : parsed;
};
export const BREAKPOINT_SM = getBreakpoint("sm", 512); // Tailwind sm
export const BREAKPOINT_MD = getBreakpoint("md", 768); // Tailwind md
export const BREAKPOINT_LG = getBreakpoint("lg", 1280); // Tailwind lg
export const BREAKPOINT_XL = getBreakpoint("xl", 1920); // Tailwind xl

View File

@@ -0,0 +1,26 @@
export const PAGE_SIZE = 8;
export const LIGHT_MODE = "light",
DARK_MODE = "dark",
SYSTEM_MODE = "system";
export const WALLPAPER_FULLSCREEN = "fullscreen",
WALLPAPER_BANNER = "banner",
WALLPAPER_NONE = "none";
// Navbar height (px)
export const NAVBAR_HEIGHT = 88;
// Banner height unit (vh)
export const BANNER_HEIGHT = 30;
export const BANNER_HEIGHT_EXTEND = 36;
export const BANNER_HEIGHT_HOME = BANNER_HEIGHT + BANNER_HEIGHT_EXTEND;
// The height the main panel overlaps the banner (rem)
export const MAIN_PANEL_OVERLAPS_BANNER_HEIGHT = 0;
// Page width (rem)
export const PAGE_WIDTH = 90;
// Category constants
export const UNCATEGORIZED = "uncategorized";

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

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

View File

@@ -0,0 +1,67 @@
import { LinkPreset, type NavbarLink } from "@/types/config";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
export const LinkPresets: { [key in LinkPreset]: NavbarLink } = {
[LinkPreset.Home]: {
name: i18n(I18nKey.home),
url: "/",
icon: "material-symbols:home",
description: "Twilight - A simple, clean, and beautiful blog theme.",
},
[LinkPreset.Archive]: {
name: i18n(I18nKey.archive),
url: "/archive/",
icon: "material-symbols:archive",
description: "A chronological list of all published posts.",
},
[LinkPreset.Projects]: {
name: i18n(I18nKey.projects),
url: "/projects/",
icon: "material-symbols:work",
description: i18n(I18nKey.projectsSubtitle),
},
[LinkPreset.Skills]: {
name: i18n(I18nKey.skills),
url: "/skills/",
icon: "material-symbols:psychology",
description: i18n(I18nKey.skillsSubtitle),
},
[LinkPreset.Timeline]: {
name: i18n(I18nKey.timeline),
url: "/timeline/",
icon: "material-symbols:timeline",
description: i18n(I18nKey.timelineSubtitle),
},
[LinkPreset.Diary]: {
name: i18n(I18nKey.diary),
url: "/diary/",
icon: "material-symbols:book",
description: i18n(I18nKey.diarySubtitle),
},
[LinkPreset.Albums]: {
name: i18n(I18nKey.albums),
url: "/albums/",
icon: "material-symbols:photo-library",
description: i18n(I18nKey.albumsSubtitle),
},
[LinkPreset.Anime]: {
name: i18n(I18nKey.anime),
url: "/anime/",
icon: "material-symbols:movie",
description: "A list of anime I have watched.",
},
[LinkPreset.Friends]: {
name: i18n(I18nKey.friends),
url: "/friends/",
icon: "material-symbols:group",
description: "A curated list of friend sites.",
},
[LinkPreset.About]: {
name: i18n(I18nKey.about),
url: "/about/",
icon: "material-symbols:info",
description: i18n(I18nKey.about),
},
};

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

@@ -0,0 +1,56 @@
import { defineCollection } from "astro:content";
import { z } from 'astro/zod';
import { glob } from 'astro/loaders';
// Helper for handling dates that might be empty strings from JSON
const dateSchema = z.preprocess((arg) => {
if (typeof arg === "string" && arg.trim() === "") return undefined;
return arg;
}, z.coerce.date());
const optionalDateSchema = z.preprocess((arg) => {
if (typeof arg === "string" && arg.trim() === "") return undefined;
return arg;
}, z.coerce.date().optional());
const postsCollection = defineCollection({
loader: glob({ pattern: '**/[^_]*.{md,mdx}', base: "./src/content/posts" }),
schema: z.object({
title: z.string(),
published: dateSchema,
updated: optionalDateSchema,
draft: z.boolean().optional().default(false),
description: z.string().optional().default(""),
cover: 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 routeName */
routeName: 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,mdx}', base: "./src/content" }),
schema: z.object({}),
});
export const collections = {
posts: postsCollection,
spec: specCollection,
};

18
src/content/about.md Normal file
View File

@@ -0,0 +1,18 @@
# About This Site
Twilight is a CMS integrated static blog template built with [Astro](https://astro.build) framework.
::github{repo="Spr-Aachen/Twilight"}
## Milestones
- Integrated content management system in [#1](https://github.com/Spr-Aachen/Twilight/pull/1)
- Seamless wallpaper mode switching in [#2](https://github.com/Spr-Aachen/Twilight/pull/2)
- Dynamic content container response in [#47](https://github.com/Spr-Aachen/Twilight/pull/47)
- Seamless page component loading in [#48](https://github.com/Spr-Aachen/Twilight/pull/48)
- Smart music playback handling in [#49](https://github.com/Spr-Aachen/Twilight/pull/49)
- Infinite-level navigation system in [#50](https://github.com/Spr-Aachen/Twilight/pull/50)
- Comprehensive statistics dashboard in [#51](https://github.com/Spr-Aachen/Twilight/pull/51)
- Low-barrier configuration scheme in [#58](https://github.com/Spr-Aachen/Twilight/pull/58)
- Dynamic breakpoint handling in [#68](https://github.com/Spr-Aachen/Twilight/pull/68)

View File

@@ -0,0 +1,29 @@
{
"title": "Album Example",
"description": "This is an example album. Note that the local image path is relative to the corresponding album folder in the public directory.",
"cover": "https://picsum.photos/800/600?random=1",
"date": "2025-01-01T00:00:00.000Z",
"location": "Unknown",
"tags": ["example"],
"layout": "masonry",
"columns": 3,
"photos": [
{
"src": "智子_ASK.jpg",
"alt": "智子",
"title": "智子 by ASK",
"description": "This is a beautiful image from local file",
"tags": ["human"]
},
{
"src": "https://picsum.photos/800/600?random=2",
"alt": "random image",
"title": "Random Image",
"description": "This is a beautiful image from external link",
"tags": [
"scenary"
]
}
],
"visible": true
}

View File

@@ -0,0 +1,5 @@
{
"content": "The Darkest Hour Is Just Before The Dawn",
"date": "2020-02-02T00:00:00Z",
"images": []
}

18
src/content/friends.md Normal file
View File

@@ -0,0 +1,18 @@
---
## How to Apply
Please send your website information to: xxx@xxx.com
Email Subject:
```
Request to Add Friend
```
Email Content (Template):
```
Site Name: [Your Site Name]
Site Desc: [Your Site Description]
Site Link: [Your Site Link]
Avatar Link: [Your Avatar Link]
```

View File

@@ -0,0 +1,7 @@
{
"title": "Astro",
"imgurl": "https://avatars.githubusercontent.com/u/44914786?s=210&v=4",
"desc": "The web framework for content-driven websites.",
"siteurl": "https://github.com/withastro/astro",
"tags": ["Framework"]
}

View File

@@ -0,0 +1,7 @@
{
"title": "Spr_Aachen's Blog",
"imgurl": "https://blog.spr-aachen.com/assets/images/avatar.png",
"desc": "Spr_Aachen's Personal Blog",
"siteurl": "https://blog.spr-aachen.com",
"tags": ["Blog"]
}

View File

@@ -0,0 +1,7 @@
{
"title": "Twilight Docs",
"imgurl": "https://docs.twilight.spr-aachen.com/twilight.png",
"desc": "Twilight User Manual",
"siteurl": "https://docs.twilight.spr-aachen.com",
"tags": ["Docs"]
}

View File

@@ -0,0 +1,23 @@
---
title: Draft Example
published: 2021-12-02
tags: [Markdown]
category: Examples
draft: true
---
# This Article is a Draft
This article is currently in a draft state and is not published. Therefore, it will not be visible to the general audience. The content is still a work in progress and may require further editing and review.
When the article is ready for publication, you can update the "draft" field to "false" in the Frontmatter:
```markdown
---
title: Draft Example
published: 2024-01-11T04:40:26.381Z
tags: [Markdown]
category: Examples
draft: false
---

View File

@@ -0,0 +1,38 @@
---
title: Encryption Example
published: 2020-02-02
description: 'Password: 123456'
encrypted: true
pinned: false
password: "123456"
tags: [Encryption]
category: Examples
---
# Password Protected Post
This is an example of a password-protected post in the Twilight theme. The content below is encrypted using AES and can only be viewed by entering the correct password.
## Frontmatter Example
```yaml
---
title: Encryption Example
published: 2020-02-02
encrypted: true
password: "your-password"
...
---
```
- `encrypted` - Whether encryption is enabled for the post.
- `password` - The password required to unlock the content.
## Note
:::warning
Do not use this for extremely sensitive information like bank passwords or private keys. The encryption happens on the client side, and the password itself is stored in the post's metadata (though usually not displayed directly).
:::

View File

@@ -0,0 +1,96 @@
---
title: Extended Features
published: 2010-01-02
updated: 2020-02-02
description: 'Read more about Markdown features in Twilight'
image: ''
tags: [Markdown]
category:
draft: false
---
## GitHub Repository Cards
You can add dynamic cards that link to GitHub repositories, on page load, the repository information is pulled from the GitHub API.
::github{repo="Spr-Aachen/Twilight"}
Create a GitHub repository card with the code `::github{repo="Spr-Aachen/Twilight"}`.
```markdown
::github{repo="Spr-Aachen/Twilight"}
```
## Admonitions
Following types of admonitions are supported: `note` `tip` `important` `warning` `caution`
:::note
Highlights information that users should take into account, even when skimming.
:::
:::tip
Optional information to help a user be more successful.
:::
:::important
Crucial information necessary for users to succeed.
:::
:::warning
Critical content demanding immediate user attention due to potential risks.
:::
:::caution
Negative potential consequences of an action.
:::
### Basic Syntax
```markdown
:::note
Highlights information that users should take into account, even when skimming.
:::
:::tip
Optional information to help a user be more successful.
:::
```
### Custom Titles
The title of the admonition can be customized.
:::note[MY CUSTOM TITLE]
This is a note with a custom title.
:::
```markdown
:::note[MY CUSTOM TITLE]
This is a note with a custom title.
:::
```
### GitHub Syntax
> [!TIP]
> [The GitHub syntax](https://github.com/orgs/community/discussions/16925) is also supported.
```
> [!NOTE]
> The GitHub syntax is also supported.
> [!TIP]
> The GitHub syntax is also supported.
```
### Spoiler
You can add spoilers to your text. The text also supports **Markdown** syntax.
The content :spoiler[is hidden **ayyy**]!
```markdown
The content :spoiler[is hidden **ayyy**]!

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

View File

@@ -0,0 +1,56 @@
---
title: Guide for Blog Template
published: 2001-10-02
description: "How to use this blog template."
cover: "./cover.jpg"
pinned: true
tags: []
category: Guides
draft: false
---
Tip: For the things that are not mentioned in this guide, you may find the answers in the [Astro Docs](https://docs.astro.build/).
## Front-matter of Posts
```yaml
---
title: My First Blog Post
published: 2020-02-02
description: This is the first post of my new Astro blog.
cover: ./cover.jpg
tags: [Foo, Bar]
category: Front-end
draft: false
---
```
| Attribute | Description |
|---------------|---------------|
| `title` | The title of the post. |
| `published` | The date the post was published. |
| `pinned` | Whether this post is pinned to the top of the post list. |
| `description` | A short description of the post. Displayed on index page. |
| `cover` | The cover image path of the post. <br/>1. Start with `http://` or `https://`: For web image <br/>2. Start with `/`: For image in `public` dir <br/>3. With none of the prefixes: Relative to the markdown file |
| `tags` | The tags of the post. |
| `category` | The category of the post. |
| `licenseName` | The license name for the post content. |
| `author` | The author of the post. |
| `sourceLink` | The source link or reference for the post content. |
| `draft` | If this post is still a draft, which won't be displayed. |
## Where to Place the Post Files
Your post files should be placed in `src/content/posts/` directory. You can also create sub-directories to better organize your posts and assets.
```
src/content/posts/
├── post-1.md
└── post-2/
├── cover.jpg
└── index.md
```

View File

@@ -0,0 +1,198 @@
---
title: Mermaid Example
published: 2011-11-02
pinned: false
description: A simple example of a Markdown blog post with Mermaid.
tags: [Markdown, Mermaid]
category: Examples
draft: false
---
# Complete Guide to Markdown with Mermaid Diagrams
This article demonstrates how to create various complex diagrams using Mermaid in Markdown documents, including flowcharts, sequence diagrams, Gantt charts, class diagrams, and state diagrams.
## Flowchart Example
Flowcharts are excellent for representing processes or algorithm steps.
```mermaid
graph TD
A[Start] --> B{Condition Check}
B -->|Yes| C[Process Step 1]
B -->|No| D[Process Step 2]
C --> E[Subprocess]
D --> E
subgraph E [Subprocess Details]
E1[Substep 1] --> E2[Substep 2]
E2 --> E3[Substep 3]
end
E --> F{Another Decision}
F -->|Option 1| G[Result 1]
F -->|Option 2| H[Result 2]
F -->|Option 3| I[Result 3]
G --> J[End]
H --> J
I --> J
```
## Sequence Diagram Example
Sequence diagrams show interactions between objects over time.
```mermaid
sequenceDiagram
participant User
participant WebApp
participant Server
participant Database
User->>WebApp: Submit Login Request
WebApp->>Server: Send Auth Request
Server->>Database: Query User Credentials
Database-->>Server: Return User Data
Server-->>WebApp: Return Auth Result
alt Auth Successful
WebApp->>User: Show Welcome Page
WebApp->>Server: Request User Data
Server->>Database: Get User Preferences
Database-->>Server: Return Preferences
Server-->>WebApp: Return User Data
WebApp->>User: Load Personalized Interface
else Auth Failed
WebApp->>User: Show Error Message
WebApp->>User: Prompt Re-entry
end
```
## Gantt Chart Example
Gantt charts are perfect for displaying project schedules and timelines.
```mermaid
gantt
title Website Development Project Timeline
dateFormat YYYY-MM-DD
axisFormat %m/%d
section Design Phase
Requirements Analysis :a1, 2023-10-01, 7d
UI Design :a2, after a1, 10d
Prototype Creation :a3, after a2, 5d
section Development Phase
Frontend Development :b1, 2023-10-20, 15d
Backend Development :b2, after a2, 18d
Database Design :b3, after a1, 12d
section Testing Phase
Unit Testing :c1, after b1, 8d
Integration Testing :c2, after b2, 10d
User Acceptance Testing :c3, after c2, 7d
section Deployment
Production Deployment :d1, after c3, 3d
Launch :milestone, after d1, 0d
```
## Class Diagram Example
Class diagrams show the static structure of a system, including classes, attributes, methods, and their relationships.
```mermaid
classDiagram
class User {
+String username
+String password
+String email
+Boolean active
+login()
+logout()
+updateProfile()
}
class Article {
+String title
+String content
+Date publishDate
+Boolean published
+publish()
+edit()
+delete()
}
class Comment {
+String content
+Date commentDate
+addComment()
+deleteComment()
}
class Category {
+String name
+String description
+addArticle()
+removeArticle()
}
User "1" -- "*" Article : writes
User "1" -- "*" Comment : posts
Article "1" -- "*" Comment : has
Article "1" -- "*" Category : belongs to
```
## State Diagram Example
State diagrams show the sequence of states an object goes through during its life cycle.
```mermaid
stateDiagram-v2
[*] --> Draft
Draft --> UnderReview : submit
UnderReview --> Draft : reject
UnderReview --> Approved : approve
Approved --> Published : publish
Published --> Archived : archive
Published --> Draft : retract
state Published {
[*] --> Active
Active --> Hidden : temporarily hide
Hidden --> Active : restore
Active --> [*]
Hidden --> [*]
}
Archived --> [*]
```
## Pie Chart Example
Pie charts are ideal for displaying proportions and percentage data.
```mermaid
pie title Website Traffic Sources Analysis
"Search Engines" : 45.6
"Direct Access" : 30.1
"Social Media" : 15.3
"Referral Links" : 6.4
"Other Sources" : 2.6
```
## Conclusion
Mermaid is a powerful tool for creating various types of diagrams in Markdown documents. This article demonstrated how to use flowcharts, sequence diagrams, Gantt charts, class diagrams, state diagrams, and pie charts. These diagrams can help you express complex concepts, processes, and data structures more clearly.
To use Mermaid, simply specify the mermaid language in a code block and describe the diagram using concise text syntax. Mermaid will automatically convert these descriptions into beautiful visual diagrams.
Try using Mermaid diagrams in your next technical blog post or project documentation - they will make your content more professional and easier to understand!

View File

@@ -0,0 +1,34 @@
---
title: Video Example
published: 2021-12-02
description: This post demonstrates how to embed video in a blog post.
tags: [Markdown, Video]
category: Examples
draft: false
---
## Instructions
Just copy the embed code from YouTube or other platforms, and paste it in the markdown file as below:
```yaml
---
title: Include Video in the Post
published: 2023-10-19
// ...
---
<iframe width="100%" height="468" src="https://www.youtube.com/embed/yrn7eInApnc?si=gGZeFbPcfMpJ1uV3_" title="YouTube video player" frameborder="0" allowfullscreen></iframe>
```
## Examples
### YouTube
<iframe width="100%" height="468" src="https://www.youtube.com/embed/yrn7eInApnc?si=gGZeFbPcfMpJ1uV3_" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
### Bilibili
<iframe width="100%" height="468" src="//player.bilibili.com/player.html?bvid=BV14QpMeSEuD&p=1&autoplay=0" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true" &autoplay=0> </iframe>

View File

@@ -0,0 +1,21 @@
{
"title": "Twilight Blog Template",
"description": "A CMS integrated blog theme built with Astro framework.",
"image": "",
"category": "website",
"techStack": [
"Astro",
"Svelte",
"Tailwind CSS"
],
"status": "in-progress",
"liveDemo": "https://twilight.spr-aachen.com",
"sourceCode": "https://github.com/Spr-Aachen/Twilight",
"startDate": "2025-10-01",
"endDate": "",
"featured": true,
"tags": [
"Personal Project",
"Open Source Project"
]
}

View File

@@ -0,0 +1,15 @@
{
"name": "Astro",
"description": "A modern static site generator supporting multi-framework integration.",
"icon": "logos:astro-icon",
"category": "frontend",
"level": "beginner",
"experience": {
"years": 0,
"months": 3
},
"projects": [
"Twilight"
],
"color": "#BC52EE"
}

View File

@@ -0,0 +1,12 @@
{
"name": "Git",
"description": "A distributed version control system.",
"icon": "logos:git-icon",
"category": "tools",
"level": "advanced",
"experience": {
"years": 3,
"months": 0
},
"color": "#F05032"
}

View File

@@ -0,0 +1,15 @@
{
"name": "Svelte",
"description": "A compiler-based UI framework.",
"icon": "logos:svelte-icon",
"category": "frontend",
"level": "beginner",
"experience": {
"years": 0,
"months": 3
},
"projects": [
"Twilight"
],
"color": "#FF3E00"
}

View File

@@ -0,0 +1,15 @@
{
"name": "Tailwind CSS",
"description": "A utility-first CSS framework.",
"icon": "logos:tailwindcss-icon",
"category": "frontend",
"level": "beginner",
"experience": {
"years": 0,
"months": 3
},
"projects": [
"Twilight"
],
"color": "#06B6D4"
}

View File

@@ -0,0 +1,28 @@
{
"title": "Twilight",
"description": "My first open source template project, a CMS integrated blog template built with Astro framework.",
"type": "project",
"startDate": "2025-10-01",
"endDate": "",
"skills": [
"Astro",
"Svelte",
"Tailwind CSS"
],
"achievements": [],
"links": [
{
"name": "Online Demo",
"url": "https://twilight.spr-aachen.com",
"type": "project"
},
{
"name": "GitHub Repository",
"url": "https://github.com/Spr-Aachen/Twilight",
"type": "project"
}
],
"icon": "material-symbols:code",
"color": "#7C3AED",
"featured": true
}

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

@@ -0,0 +1,2 @@
/// <reference types="astro/client" />
/// <reference path="../.astro/types.d.ts" />

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

@@ -0,0 +1,82 @@
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;
semifullScrollHandler: (() => void) | null;
closeAnnouncement: () => void;
iconifyLoaded: boolean;
__iconifyLoader: {
load: () => Promise<void>;
};
pagefind: {
search: (query: string) => Promise<{
results: Array<{
data: () => Promise<SearchResult>;
}>;
}>;
};
translate?: {
service: {
use: (service: string) => void;
};
language: {
setLocal: (language: string) => void;
};
setAutoDiscriminateLocalLanguage: () => void;
ignore: {
class: string[];
tag: string[];
};
selectLanguageTag: {
show: boolean;
};
storage: {
set: () => void;
};
listener: {
start: () => void;
};
execute: () => void;
};
mobileTOCInit?: () => void;
loadTranslateScript?: () => Promise<void>;
getUmamiWebsiteStats?: (baseUrl: string, apiKey: string, websiteId: string) => Promise<any>;
getUmamiPageStats?: (baseUrl: string, apiKey: string, websiteId: string, urlPath: string, startAt?: number, endAt?: number) => Promise<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[];
}
export { SearchResult };

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

@@ -0,0 +1,273 @@
enum I18nKey {
// 加载
loading = "loading",
// 导航
home = "home",
archive = "archive",
about = "about",
search = "search",
// 文章
tags = "tags",
categories = "categories",
series = "series",
posts = "posts",
recentPosts = "recentPosts",
postList = "postList",
statistics = "statistics",
tableOfContents = "tableOfContents",
// 统计
pageViews = "pageViews",
visitors = "visitors",
statsLoading = "statsLoading",
statsError = "statsError",
// 公告栏
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",
wallpaperMode = "wallpaperMode",
wallpaperFullscreen = "wallpaperFullscreen",
wallpaperBanner = "wallpaperBanner",
wallpaperNone = "wallpaperNone",
more = "more",
backTo = "backTo",
author = "author",
publishedAt = "publishedAt",
license = "license",
// 404页面
notFound = "notFound",
notFoundTitle = "notFoundTitle",
notFoundDescription = "notFoundDescription",
backToHome = "backToHome",
// 项目展示页面
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",
// 技能展示页面
skills = "skills",
skillsSubtitle = "skillsSubtitle",
skillsAI = "skillsAI",
skillsBackend = "skillsBackend",
skillsClient = "skillsClient",
skillsFrontend = "skillsFrontend",
skillsDatabase = "skillsDatabase",
skillsEngines = "skillsEngines",
skillsTools = "skillsTools",
skillsOthers = "skillsOthers",
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",
// 时间线页面
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",
timelineStatistics = "timelineStatistics",
timelineByType = "timelineByType",
timelineWorkExperience = "timelineWorkExperience",
timelineTotalExperience = "timelineTotalExperience",
timelineWorkPositions = "timelineWorkPositions",
timelineCurrentRole = "timelineCurrentRole",
timelineEmployed = "timelineEmployed",
timelineAvailable = "timelineAvailable",
// 短文页面
diary = "diary",
diarySubtitle = "diarySubtitle",
diaryCount = "diaryCount",
diaryImage = "diaryImage",
diaryReply = "diaryReply",
diaryTips = "diaryTips",
diaryMinutesAgo = "diaryMinutesAgo",
diaryHoursAgo = "diaryHoursAgo",
diaryDaysAgo = "diaryDaysAgo",
// 相册页面
albums = "albums",
albumsSubtitle = "albumsSubtitle",
albumsEmpty = "albumsEmpty",
albumsEmptyDesc = "albumsEmptyDesc",
albumsBackToList = "albumsBackToList",
albumsPhotoCount = "albumsPhotoCount",
albumsPhotosCount = "albumsPhotosCount",
// 番剧页面
anime = "anime",
animeTitle = "animeTitle",
animeSubtitle = "animeSubtitle",
animeList = "animeList",
animeTotal = "animeTotal",
animeWatching = "animeWatching",
animeCompleted = "animeCompleted",
animeAvgRating = "animeAvgRating",
animeStatusWatching = "animeStatusWatching",
animeStatusCompleted = "animeStatusCompleted",
animeStatusPlanned = "animeStatusPlanned",
animeYear = "animeYear",
animeStudio = "animeStudio",
animeEmpty = "animeEmpty",
animeEmptyBangumi = "animeEmptyBangumi",
// 友链页面
friends = "friends",
// RSS页面
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 = "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",
// 密码保护
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",
// 音乐播放器
playlist = "playlist",
musicEmptyPlaylist = "musicEmptyPlaylist",
musicNoSongsAvailable = "musicNoSongsAvailable",
musicPlayFailed = "musicPlayFailed",
musicAutoplayBlocked = "musicAutoplayBlocked",
musicMetingFailed = "musicMetingFailed",
musicUnknownArtist = "musicUnknownArtist",
musicUnknownTrack = "musicUnknownTrack",
musicSwitchToLocal = "musicSwitchToLocal",
musicSwitchToMeting = "musicSwitchToMeting",
musicProgress = "musicProgress",
musicCollapse = "musicCollapse",
musicVolume = "musicVolume",
musicExpand = "musicExpand",
}
export default I18nKey;

149
src/i18n/language.ts Normal file
View File

@@ -0,0 +1,149 @@
/**
* 统一的语言配置文件
* 所有语言相关的映射和配置都从这里导出
*/
export interface LanguageConfig {
/** 翻译服务使用的语言代码 */
translateCode: string;
/** 语言显示名称 */
displayName: string;
/** Intl.DateTimeFormat 使用的 locale */
locale: string;
/** 语言图标(国旗 emoji */
icon: string;
}
/**
* 支持的语言配置
* 单一数据源,避免重复定义
*/
export const LANGUAGE_CONFIG = {
zh: {
translateCode: "chinese_simplified",
displayName: "中文",
locale: "zh-CN",
icon: "🇨🇳",
},
en: {
translateCode: "english",
displayName: "English",
locale: "en-US",
icon: "🇺🇸",
},
ja: {
translateCode: "japanese",
displayName: "日本語",
locale: "ja-JP",
icon: "🇯🇵",
},
ko: {
translateCode: "korean",
displayName: "한국어",
locale: "ko-KR",
icon: "🇰🇷",
},
es: {
translateCode: "spanish",
displayName: "Español",
locale: "es-ES",
icon: "🇪🇸",
},
th: {
translateCode: "thai",
displayName: "ไทย",
locale: "th-TH",
icon: "🇹🇭",
},
vi: {
translateCode: "vietnamese",
displayName: "Tiếng Việt",
locale: "vi-VN",
icon: "🇻🇳",
},
tr: {
translateCode: "turkish",
displayName: "Türkçe",
locale: "tr-TR",
icon: "🇹🇷",
},
id: {
translateCode: "indonesian",
displayName: "Bahasa Indonesia",
locale: "id-ID",
icon: "🇮🇩",
},
fr: {
translateCode: "french",
displayName: "Français",
locale: "fr-FR",
icon: "🇫🇷",
},
de: {
translateCode: "german",
displayName: "Deutsch",
locale: "de-DE",
icon: "🇩🇪",
},
ru: {
translateCode: "russian",
displayName: "Русский",
locale: "ru-RU",
icon: "🇷🇺",
},
ar: {
translateCode: "arabic",
displayName: "العربية",
locale: "ar-SA",
icon: "🇸🇦",
},
} as const satisfies Record<string, LanguageConfig>;
/** 支持的语言代码列表 */
export const SUPPORTED_LANGUAGES = Object.keys(LANGUAGE_CONFIG) as Array<
keyof typeof LANGUAGE_CONFIG
>;
export type SupportedLanguage = keyof typeof LANGUAGE_CONFIG;
/**
* 配置文件语言代码到翻译服务语言代码的映射
* 自动从 LANGUAGE_CONFIG 生成
*/
export const langToTranslateMap: Record<string, string> = Object.fromEntries(
Object.entries(LANGUAGE_CONFIG).map(([lang, config]) => [
lang,
config.translateCode,
]),
);
/**
* 翻译服务语言代码到配置文件语言代码的映射
* 自动从 LANGUAGE_CONFIG 生成
*/
export const translateToLangMap: Record<string, string> = Object.fromEntries(
Object.entries(LANGUAGE_CONFIG).map(([lang, config]) => [
config.translateCode,
lang,
]),
);
/**
* 语言代码到 locale 的映射
* 自动从 LANGUAGE_CONFIG 生成
*/
export const langToLocaleMap: Record<string, string> = Object.fromEntries(
Object.entries(LANGUAGE_CONFIG).map(([lang, config]) => [lang, config.locale]),
);
/**
* 获取所有支持翻译的语言列表(用于 Translator
*/
export function getSupportedTranslateLanguages() {
return Object.entries(LANGUAGE_CONFIG).map(([code, config]) => ({
code: config.translateCode,
name: config.displayName,
icon: config.icon,
langCode: code,
}));
}

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

@@ -0,0 +1,274 @@
import Key from "@i18n/i18nKey";
import type { Translation } from "@i18n/translation";
export const en: Translation = {
// Loading Overlay
[Key.loading]: "LOADING",
// Navigations
[Key.home]: "Home",
[Key.archive]: "Archive",
[Key.about]: "About",
[Key.search]: "Search",
// Posts
[Key.tags]: "Tags",
[Key.categories]: "Categories",
[Key.series]: "Series",
[Key.posts]: "Posts",
[Key.recentPosts]: "Recent Posts",
[Key.postList]: "Post List",
[Key.statistics]: "Statistics",
[Key.tableOfContents]: "Table of Contents",
// Page Stats
[Key.pageViews]: "Views",
[Key.visitors]: "Visitors",
[Key.statsLoading]: "Loading stats...",
[Key.statsError]: "Stats unavailable",
// Announcement
[Key.announcement]: "Announcement",
[Key.announcementClose]: "Close",
[Key.comments]: "Comments",
[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.wallpaperMode]: "Wallpaper Mode",
[Key.wallpaperFullscreen]: "Fullscreen",
[Key.wallpaperBanner]: "Banner",
[Key.wallpaperNone]: "None",
[Key.more]: "More",
[Key.backTo]: "Back to",
[Key.author]: "Author",
[Key.publishedAt]: "Published at",
[Key.license]: "License",
// 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",
// 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",
// Skills Page
[Key.skills]: "Skills",
[Key.skillsSubtitle]: "My technical skills and expertise",
[Key.skillsAI]: "AI Development",
[Key.skillsBackend]: "Backend Development",
[Key.skillsClient]: "Client Development",
[Key.skillsFrontend]: "Frontend Development",
[Key.skillsDatabase]: "Database",
[Key.skillsEngines]: "Engines",
[Key.skillsTools]: "Development Tools",
[Key.skillsOthers]: "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]: "Distribution by Level",
[Key.skillsByCategory]: "Distribution by Category",
// 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.timelineStatistics]: "Statistics",
[Key.timelineByType]: "Grouped by Type",
[Key.timelineWorkExperience]: "Work Experience",
[Key.timelineTotalExperience]: "Total Work Experience",
[Key.timelineWorkPositions]: "Work Positions",
[Key.timelineCurrentRole]: "Current Status",
[Key.timelineEmployed]: "Employed",
[Key.timelineAvailable]: "Available",
// Diary Page
[Key.diary]: "Diary",
[Key.diarySubtitle]: "Share life anytime, anywhere",
[Key.diaryCount]: "diary entries",
[Key.diaryImage]: "Image",
[Key.diaryReply]: "Reply",
[Key.diaryTips]: "Only show the latest 30 diary entries",
[Key.diaryMinutesAgo]: "minutes ago",
[Key.diaryHoursAgo]: "hours ago",
[Key.diaryDaysAgo]: "days ago",
// 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",
[Key.albumsPhotoCount]: "photo",
[Key.albumsPhotosCount]: "photos",
// Anime Page
[Key.anime]: "Anime",
[Key.animeTitle]: "My Anime List",
[Key.animeSubtitle]: "Record my anime journey",
[Key.animeList]: "Anime List",
[Key.animeTotal]: "Total",
[Key.animeWatching]: "Watching",
[Key.animeCompleted]: "Completed",
[Key.animeAvgRating]: "Average Rating",
[Key.animeStatusWatching]: "Watching",
[Key.animeStatusCompleted]: "Completed",
[Key.animeStatusPlanned]: "Planned",
[Key.animeYear]: "Year",
[Key.animeStudio]: "Studio",
[Key.animeEmpty]: "No anime data",
[Key.animeEmptyBangumi]: "Please check Bangumi configuration or network connection",
// Friends Page
[Key.friends]: "Friends",
// 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 Link",
[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 Link",
[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",
// 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]: "year",
[Key.month]: "month",
[Key.day]: "day",
[Key.hour]: "hour",
[Key.minute]: "minute",
[Key.second]: "second",
// Music Player
[Key.playlist]: "Playlist",
[Key.musicEmptyPlaylist]: "Local playlist is empty",
[Key.musicNoSongsAvailable]: "No songs available in playlist",
[Key.musicPlayFailed]: "Failed to play \"{0}\", trying next...",
[Key.musicAutoplayBlocked]: "Autoplay blocked",
[Key.musicMetingFailed]: "Failed to fetch Meting playlist",
[Key.musicUnknownArtist]: "Unknown Artist",
[Key.musicUnknownTrack]: "Unknown Track",
[Key.musicSwitchToLocal]: "Switch to Local mode",
[Key.musicSwitchToMeting]: "Switch to Meting mode",
[Key.musicProgress]: "Progress",
[Key.musicCollapse]: "Collapse player",
[Key.musicVolume]: "Volume",
[Key.musicExpand]: "Expand music player",
};

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

@@ -0,0 +1,274 @@
import Key from "@i18n/i18nKey";
import type { Translation } from "@i18n/translation";
export const ja: Translation = {
// 加載遮罩
[Key.loading]: "読み込み中",
// ナビゲーション
[Key.home]: "ホーム",
[Key.archive]: "アーカイブ",
[Key.about]: "について",
[Key.search]: "検索",
// 投稿
[Key.tags]: "タグ",
[Key.categories]: "カテゴリ",
[Key.series]: "シリーズ",
[Key.posts]: "投稿",
[Key.recentPosts]: "最近の投稿",
[Key.postList]: "投稿リスト",
[Key.statistics]: "統計",
[Key.tableOfContents]: "目次",
// 統計
[Key.pageViews]: "閲覧数",
[Key.visitors]: "訪問者",
[Key.statsLoading]: "統計を読み込み中...",
[Key.statsError]: "統計を利用できません",
// お知らせ
[Key.announcement]: "お知らせ",
[Key.announcementClose]: "閉じる",
[Key.comments]: "コメント",
[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.wallpaperMode]: "壁紙モード",
[Key.wallpaperFullscreen]: "全画面",
[Key.wallpaperBanner]: "バナー",
[Key.wallpaperNone]: "なし",
[Key.more]: "もっと",
[Key.backTo]: "戻る",
[Key.author]: "著者",
[Key.publishedAt]: "公開日",
[Key.license]: "ライセンス",
// 404ページ
[Key.notFound]: "404",
[Key.notFoundTitle]: "ページが見つかりません",
[Key.notFoundDescription]: "申し訳ありませんが、アクセスしたページは存在しないか、移動されています。",
[Key.backToHome]: "ホームに戻る",
// プロジェクトページ
[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.skills]: "スキル",
[Key.skillsSubtitle]: "私の技術スキルと専門知識",
[Key.skillsAI]: "AI開発",
[Key.skillsBackend]: "バックエンド開発",
[Key.skillsClient]: "クライアント開発",
[Key.skillsFrontend]: "フロントエンド開発",
[Key.skillsDatabase]: "データベース",
[Key.skillsEngines]: "エンジン",
[Key.skillsTools]: "開発ツール",
[Key.skillsOthers]: "その他のスキル",
[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.timelineStatistics]: "統計",
[Key.timelineByType]: "タイプ別グループ化",
[Key.timelineWorkExperience]: "職歴",
[Key.timelineTotalExperience]: "総職歴",
[Key.timelineWorkPositions]: "職位数",
[Key.timelineCurrentRole]: "現在の状態",
[Key.timelineEmployed]: "在職中",
[Key.timelineAvailable]: "入社可能",
// 日記ページ
[Key.diary]: "日記",
[Key.diarySubtitle]: "いつでもどこでも、生活を共有する",
[Key.diaryCount]: "日記エントリ",
[Key.diaryImage]: "画像",
[Key.diaryReply]: "返信",
[Key.diaryTips]: "最新の30件の日記エントリのみを表示",
[Key.diaryMinutesAgo]: "分前",
[Key.diaryHoursAgo]: "時間前",
[Key.diaryDaysAgo]: "日前",
// アルバムページ
[Key.albums]: "アルバム",
[Key.albumsSubtitle]: "生活の美しい瞬間を記録する",
[Key.albumsEmpty]: "コンテンツなし",
[Key.albumsEmptyDesc]: "アルバムがまだ作成されていません。美しい思い出を追加してください!",
[Key.albumsBackToList]: "アルバムに戻る",
[Key.albumsPhotoCount]: "写真",
[Key.albumsPhotosCount]: "写真",
// アニメページ
[Key.anime]: "アニメ",
[Key.animeTitle]: "私のアニメリスト",
[Key.animeSubtitle]: "私の二次元の旅を記録する",
[Key.animeList]: "アニメリスト",
[Key.animeTotal]: "合計",
[Key.animeWatching]: "視聴中",
[Key.animeCompleted]: "完了",
[Key.animeAvgRating]: "平均評価",
[Key.animeStatusWatching]: "視聴中",
[Key.animeStatusCompleted]: "完了",
[Key.animeStatusPlanned]: "予定",
[Key.animeYear]: "年",
[Key.animeStudio]: "スタジオ",
[Key.animeEmpty]: "アニメデータなし",
[Key.animeEmptyBangumi]: "Bangumiの設定またはネットワーク接続を確認してください",
// 友達ページ
[Key.friends]: "友達",
// RSSページ
[Key.rss]: "RSSフィード",
[Key.rssDescription]: "最新の更新を購読する",
[Key.rssSubtitle]: "RSSで購読して、最新の記事と更新を第一时间で取得する",
[Key.rssLink]: "RSSリンク",
[Key.rssCopyToReader]: "RSSリンクをリーダーにコピー",
[Key.rssCopyLink]: "リンクをコピー",
[Key.rssLatestPosts]: "最新の投稿",
[Key.rssWhatIsRSS]: "RSSとは",
[Key.rssWhatIsRSSDescription]: "RSSReally Simple Syndicationは、頻繁に更新されるコンテンツを公開するための標準形式です。RSSを使用すると",
[Key.rssBenefit1]: "手動で訪問することなく、最新のウェブサイトコンテンツを及时に取得",
[Key.rssBenefit2]: "1か所で複数のウェブサイトの購読を管理",
[Key.rssBenefit3]: "重要な更新や記事を見逃すことを回避",
[Key.rssBenefit4]: "広告なしのクリーンな読書体験を楽しむ",
[Key.rssHowToUse]: "Feedly、Inoreaderまたは他のRSSリーダーを使用してこのサイトを購読することを推奨します。",
[Key.rssCopied]: "RSSリンクがクリップボードにコピーされました",
[Key.rssCopyFailed]: "コピーに失敗しました。手動でリンクをコピーしてください",
// Atomページ
[Key.atom]: "Atomフィード",
[Key.atomDescription]: "最新の更新を購読する",
[Key.atomSubtitle]: "Atomで購読して、最新の記事と更新を第一时间で取得する",
[Key.atomLink]: "Atomリンク",
[Key.atomCopyToReader]: "Atomリンクをリーダーにコピー",
[Key.atomCopyLink]: "リンクをコピー",
[Key.atomLatestPosts]: "最新の投稿",
[Key.atomWhatIsAtom]: "Atomとは",
[Key.atomWhatIsAtomDescription]: "Atom連合フォーマットAtom Syndication Formatは、フィードとそのアイテムを記述するためのXMLベースの標準です。Atomを使用すると",
[Key.atomBenefit1]: "手動で訪問することなく、最新のウェブサイトコンテンツを及时に取得",
[Key.atomBenefit2]: "1か所で複数のウェブサイトの購読を管理",
[Key.atomBenefit3]: "重要な更新や記事を見逃すことを回避",
[Key.atomBenefit4]: "広告なしのクリーンな読書体験を楽しむ",
[Key.atomHowToUse]: "Feedly、Inoreaderまたは他のAtomリーダーを使用してこのサイトを購読することを推奨します。",
[Key.atomCopied]: "Atomリンクがクリップボードにコピーされました",
[Key.atomCopyFailed]: "コピーに失敗しました。手動でリンクをコピーしてください",
// パスワード保護
[Key.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.playlist]: "プレイリスト",
[Key.musicEmptyPlaylist]: "ローカルプレイリストが空です",
[Key.musicNoSongsAvailable]: "プレイリストに利用可能な曲がありません",
[Key.musicPlayFailed]: "\"{0}\" の再生に失敗しました。次を試行しています...",
[Key.musicAutoplayBlocked]: "自動再生がブロックされました",
[Key.musicMetingFailed]: "Meting プレイリストの取得に失敗しました",
[Key.musicUnknownArtist]: "未知のアーティスト",
[Key.musicUnknownTrack]: "未知の曲",
[Key.musicSwitchToLocal]: "ローカルモードに切り替え",
[Key.musicSwitchToMeting]: "Metingモードに切り替え",
[Key.musicProgress]: "再生の進捗",
[Key.musicCollapse]: "プレイヤーを閉じる",
[Key.musicVolume]: "音量調節",
[Key.musicExpand]: "音楽プレイヤーを展開",
};

274
src/i18n/languages/zh.ts Normal file
View File

@@ -0,0 +1,274 @@
import Key from "@i18n/i18nKey";
import type { Translation } from "@i18n/translation";
export const zh: Translation = {
// 加载遮罩
[Key.loading]: "加载中",
// 导航
[Key.home]: "主页",
[Key.archive]: "归档",
[Key.about]: "关于",
[Key.search]: "搜索",
// 文章
[Key.tags]: "标签",
[Key.categories]: "分类",
[Key.series]: "系列",
[Key.posts]: "文章",
[Key.recentPosts]: "最新文章",
[Key.postList]: "文章列表",
[Key.statistics]: "统计",
[Key.tableOfContents]: "目录",
// 统计
[Key.pageViews]: "浏览量",
[Key.visitors]: "访客",
[Key.statsLoading]: "统计加载中...",
[Key.statsError]: "统计不可用",
// 公告栏
[Key.announcement]: "公告",
[Key.announcementClose]: "关闭",
[Key.comments]: "评论",
[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.wallpaperMode]: "壁纸模式",
[Key.wallpaperFullscreen]: "全屏壁纸",
[Key.wallpaperBanner]: "横幅壁纸",
[Key.wallpaperNone]: "纯色背景",
[Key.more]: "更多",
[Key.backTo]: "返回",
[Key.author]: "作者",
[Key.publishedAt]: "发布于",
[Key.license]: "许可协议",
// 404页面
[Key.notFound]: "404",
[Key.notFoundTitle]: "页面未找到",
[Key.notFoundDescription]: "抱歉,您访问的页面不存在或已被移动。",
[Key.backToHome]: "返回首页",
// 项目展示页面
[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.skills]: "技能展示",
[Key.skillsSubtitle]: "我的技术技能和专业知识",
[Key.skillsAI]: "AI 开发",
[Key.skillsBackend]: "后端开发",
[Key.skillsClient]: "客户端开发",
[Key.skillsFrontend]: "前端开发",
[Key.skillsDatabase]: "数据库",
[Key.skillsEngines]: "引擎",
[Key.skillsTools]: "开发工具",
[Key.skillsOthers]: "其他技能",
[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.timelineStatistics]: "统计信息",
[Key.timelineByType]: "按类型分组",
[Key.timelineWorkExperience]: "工作经验",
[Key.timelineTotalExperience]: "总工作经验",
[Key.timelineWorkPositions]: "工作岗位数",
[Key.timelineCurrentRole]: "当前状态",
[Key.timelineEmployed]: "在职",
[Key.timelineAvailable]: "可入职",
// 短文页面
[Key.diary]: "日记",
[Key.diarySubtitle]: "随时随地,分享生活",
[Key.diaryCount]: "条短文",
[Key.diaryImage]: "图片",
[Key.diaryReply]: "回复",
[Key.diaryTips]: "只展示最近30条日记",
[Key.diaryMinutesAgo]: "分钟前",
[Key.diaryHoursAgo]: "小时前",
[Key.diaryDaysAgo]: "天前",
// 相册页面
[Key.albums]: "相册",
[Key.albumsSubtitle]: "记录生活中的美好瞬间",
[Key.albumsEmpty]: "暂无内容",
[Key.albumsEmptyDesc]: "还没有创建任何相册,快去添加一些美好的回忆吧!",
[Key.albumsBackToList]: "返回相册",
[Key.albumsPhotoCount]: "张照片",
[Key.albumsPhotosCount]: "张照片",
// 番剧页面
[Key.anime]: "追番",
[Key.animeTitle]: "我的追番记录",
[Key.animeSubtitle]: "记录我的二次元之旅",
[Key.animeList]: "追番列表",
[Key.animeTotal]: "总数",
[Key.animeWatching]: "追番中",
[Key.animeCompleted]: "已追完",
[Key.animeAvgRating]: "平均评分",
[Key.animeStatusWatching]: "追番中",
[Key.animeStatusCompleted]: "已追完",
[Key.animeStatusPlanned]: "计划中",
[Key.animeYear]: "年份",
[Key.animeStudio]: "制作",
[Key.animeEmpty]: "暂无追番数据",
[Key.animeEmptyBangumi]: "请检查 Bangumi 配置或网络连接",
// 友链页面
[Key.friends]: "友链",
// RSS页面
[Key.rss]: "RSS 订阅",
[Key.rssDescription]: "订阅获取最新更新",
[Key.rssSubtitle]: "通过 RSS 订阅,第一时间获取最新文章和动态",
[Key.rssLink]: "RSS 链接",
[Key.rssCopyToReader]: "复制链接到你的 RSS 阅读器",
[Key.rssCopyLink]: "复制链接",
[Key.rssLatestPosts]: "最新文章",
[Key.rssWhatIsRSS]: "什么是 RSS",
[Key.rssWhatIsRSSDescription]: "RSSReally Simple Syndication是一种用于发布经常更新内容的标准格式。通过 RSS你可以",
[Key.rssBenefit1]: "及时获取网站最新内容,无需手动访问",
[Key.rssBenefit2]: "在一个地方管理多个网站的订阅",
[Key.rssBenefit3]: "避免错过重要更新和文章",
[Key.rssBenefit4]: "享受无广告的纯净阅读体验",
[Key.rssHowToUse]: "推荐使用 Feedly、Inoreader 或其他 RSS 阅读器来订阅本站。",
[Key.rssCopied]: "RSS 链接已复制到剪贴板!",
[Key.rssCopyFailed]: "复制失败,请手动复制链接",
//Atom 页面
[Key.atom]: "Atom 订阅",
[Key.atomDescription]: "订阅获取最新更新",
[Key.atomSubtitle]: "通过 Atom 订阅,第一时间获取最新文章和动态",
[Key.atomLink]: "Atom 链接",
[Key.atomCopyToReader]: "复制链接到你的 Atom 阅读器",
[Key.atomCopyLink]: "复制链接",
[Key.atomLatestPosts]: "最新文章",
[Key.atomWhatIsAtom]: "什么是 Atom",
[Key.atomWhatIsAtomDescription]: "Atom联合格式Atom Syndication Format是一个基于XML的标准用于描述订阅源及其信息项。通过 Atom你可以",
[Key.atomBenefit1]: "及时获取网站最新内容,无需手动访问",
[Key.atomBenefit2]: "在一个地方管理多个网站的订阅",
[Key.atomBenefit3]: "避免错过重要更新和文章",
[Key.atomBenefit4]: "享受无广告的纯净阅读体验",
[Key.atomHowToUse]: "推荐使用 Feedly、Inoreader 或其他 Atom 阅读器来订阅本站。",
[Key.atomCopied]: "Atom 链接已复制到剪贴板!",
[Key.atomCopyFailed]: "复制失败,请手动复制链接",
// 密码保护
[Key.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.playlist]: "播放列表",
[Key.musicEmptyPlaylist]: "本地播放列表为空",
[Key.musicNoSongsAvailable]: "播放列表中没有可用的歌曲",
[Key.musicPlayFailed]: "无法播放 \"{0}\", 正在尝试下一首...",
[Key.musicAutoplayBlocked]: "自动播放被拦截",
[Key.musicMetingFailed]: "Meting 歌单获取失败",
[Key.musicUnknownArtist]: "未知艺术家",
[Key.musicUnknownTrack]: "未知歌曲",
[Key.musicSwitchToLocal]: "切换到 Local 模式",
[Key.musicSwitchToMeting]: "切换到 Meting 模式",
[Key.musicProgress]: "播放进度",
[Key.musicCollapse]: "折叠播放器",
[Key.musicVolume]: "音量控制",
[Key.musicExpand]: "展开音乐播放器",
};

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

@@ -0,0 +1,32 @@
import { getResolvedSiteLang } from "@utils/language";
import { en } from "./languages/en";
import { ja } from "./languages/ja";
import { zh } from "./languages/zh";
import type I18nKey from "./i18nKey";
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: zh,
zh_cn: zh,
ja: ja,
ja_jp: ja,
};
export function getTranslation(lang: string): Translation {
return map[lang.toLowerCase()] || defaultTranslation;
}
export function i18n(key: I18nKey): string {
const lang = getResolvedSiteLang();
return getTranslation(lang)[key];
}

522
src/layouts/base.astro Normal file
View File

@@ -0,0 +1,522 @@
---
import "katex/dist/katex.css";
import { siteConfig, profileConfig, umamiConfig, musicPlayerConfig, pioConfig } from "@/config";
import type { Favicon } from "@/types/config";
import { defaultFavicons } from "@constants/icon";
import {
BANNER_HEIGHT,
BANNER_HEIGHT_EXTEND,
BANNER_HEIGHT_HOME,
LIGHT_MODE,
DARK_MODE,
SYSTEM_MODE,
PAGE_WIDTH,
NAVBAR_HEIGHT,
} from "@constants/constants";
import { pathsEqual, url } from "@utils/url";
import FontLoader from "@components/fontLoader.astro";
import LoadingOverlay from "@components/loadingOverlay.astro";
import ConfigCarrier from "@components/configCarrier.astro";
import MusicPlayer from "@components/musicPlayer.svelte";
import Pio from "@components/pio.svelte";
import "@styles/navbar.css";
import "@styles/banner.css";
import "@styles/fancybox.css";
interface Props {
title?: string;
banner?: string;
description?: string;
lang?: string;
setOGTypeArticle?: boolean;
postSlug?: string;
}
let { title, banner, description, lang, setOGTypeArticle, postSlug } = Astro.props;
// apply a class to the body element to decide the height of the banner, only used for initial page load
const isHomePage = pathsEqual(Astro.url.pathname, url("/"));
// defines global css variables
// why doing this in BaseLayout instead of GlobalStyles: https://github.com/withastro/astro/issues/6728#issuecomment-1502203757
const configHue = siteConfig.themeColor.hue;
// 获取导航栏透明模式配置
const navbarTransparentMode =
siteConfig.wallpaper.banner?.navbar?.transparentMode || "semi";
// 判断是否应该显示顶部高光效果只在full和semifull模式下显示
const shouldShowTopHighlight =
navbarTransparentMode === "full" || navbarTransparentMode === "semifull";
// 获取默认banner图片的辅助函数
const getDefaultBanner = (): string => {
const src = siteConfig.wallpaper.src;
if (typeof src === "string") {
return src;
}
if (Array.isArray(src)) {
return src[0] || "";
}
if (src && typeof src === "object") {
// 优先使用desktop如果没有则使用mobile
const desktopSrc = src.desktop;
const mobileSrc = src.mobile;
if (typeof desktopSrc === "string") {
return desktopSrc;
}
if (Array.isArray(desktopSrc) && desktopSrc.length > 0) {
return desktopSrc[0];
}
if (typeof mobileSrc === "string") {
return mobileSrc;
}
if (Array.isArray(mobileSrc) && mobileSrc.length > 0) {
return mobileSrc[0];
}
}
return "";
};
// TODO don't use post cover as banner for now
banner = getDefaultBanner();
const enableBanner = siteConfig.wallpaper.mode === "banner";
let pageTitle: string;
if (title) {
pageTitle = `${title} - ${siteConfig.title}`;
} else {
pageTitle = siteConfig.subtitle
? `${siteConfig.title} - ${siteConfig.subtitle}`
: siteConfig.title;
}
let ogImageUrl: string | undefined;
if (siteConfig.generateOgImages && postSlug) {
ogImageUrl = new URL(`/og/${postSlug}.png`, Astro.site).toString();
}
const favicons: Favicon[] =
siteConfig.favicon.length > 0 ? siteConfig.favicon : defaultFavicons;
// const siteLang = siteConfig.lang.replace('_', '-')
if (!lang) {
lang = `${siteConfig.lang}`;
}
const siteLang = lang.replace("_", "-");
const bannerOffsetByPosition = {
top: `${BANNER_HEIGHT_EXTEND}vh`,
center: `${BANNER_HEIGHT_EXTEND / 2}vh`,
bottom: "0",
};
const bannerOffset =
bannerOffsetByPosition[siteConfig.wallpaper.position || "center"];
const umamiEnabled = umamiConfig.enabled || false;
const umamiScripts = umamiConfig.scripts || ""; // 获取Umami scripts配置
---
<!DOCTYPE html>
<html lang={siteLang} class="bg-(--page-bg) text-[14px] md:text-[16px] is-loading"
>
<head>
<meta charset="UTF-8" />
<!-- Resource Preconnect -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://code.iconify.design">
<!-- Font Loader -->
<FontLoader />
<!-- title -->
<title>{pageTitle}</title>
<!-- description -->
<meta name="description" content={description || pageTitle}>
<!-- keywords -->
{siteConfig.keywords && siteConfig.keywords.length > 0 && (
<meta name="keywords" content={siteConfig.keywords.join(', ')} />
)}
<!-- author -->
<meta name="author" content={profileConfig.name}>
<!-- Open Graph -->
<meta property="og:site_name" content={siteConfig.title}>
<meta property="og:url" content={Astro.url}>
<meta property="og:title" content={pageTitle}>
<meta property="og:description" content={description || pageTitle}>
{ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
{
setOGTypeArticle ? (
<meta property="og:type" content="article" />
) : (
<meta property="og:type" content="website" />
)
}
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:url" content={Astro.url}>
<meta name="twitter:title" content={pageTitle}>
<meta name="twitter:description" content={description || pageTitle}>
<!-- viewport -->
<meta name="viewport" content="width=device-width" />
<!-- generator -->
<meta name="generator" content={Astro.generator} />
<!-- favicons -->
{favicons.map(favicon => (
<link rel="icon"
href={favicon.src.startsWith('/') ? url(favicon.src) : favicon.src}
sizes={favicon.sizes}
media={favicon.theme && `(prefers-color-scheme: ${favicon.theme})`}
/>
))}
<!-- Set the theme before the page is rendered to avoid a flash -->
<script is:inline define:vars={{LIGHT_MODE, DARK_MODE, SYSTEM_MODE, BANNER_HEIGHT_EXTEND, PAGE_WIDTH, NAVBAR_HEIGHT, configHue, defaultTheme: siteConfig.defaultTheme}}>
// Load the theme from local storage
const theme = localStorage.getItem('theme') || defaultTheme;
// Apply the theme to the document
let isDark = false;
switch (theme) {
case LIGHT_MODE:
document.documentElement.classList.remove('dark');
isDark = false;
break
case DARK_MODE:
document.documentElement.classList.add('dark');
isDark = true;
break
case SYSTEM_MODE:
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
isDark = true;
} else {
document.documentElement.classList.remove('dark');
isDark = false;
}
}
const expressiveTheme = isDark ? "github-dark" : "github-light";
document.documentElement.setAttribute("data-theme", expressiveTheme);
// Load the hue from local storage
const hue = localStorage.getItem('hue') || configHue;
// Apply the hue to the document
document.documentElement.style.setProperty('--hue', hue);
// calculate the --banner-height-extend, which needs to be a multiple of 4 to avoid blurry text
let offset = Math.floor(window.innerHeight * (BANNER_HEIGHT_EXTEND / 100));
offset = offset - offset % 4;
document.documentElement.style.setProperty('--banner-height-extend', `${offset}px`);
</script>
<!-- defines global css variables. This will be applied to <html> <body> and some other elements idk why -->
<style define:vars={{
configHue,
'page-width': `${PAGE_WIDTH}rem`,
}}></style>
<!-- custom head -->
<slot name="head"></slot>
<!-- Pio 看板娘样式 - 仅在启用时加载 -->
{pioConfig.enable && <link rel="stylesheet" href="/pio/static/pio.css" />}
<!-- rss feed -->
<link rel="alternate" type="application/rss+xml" title={profileConfig.name} href={`${Astro.site}rss.xml`}/>
<!-- Umami Analytics -->
{umamiEnabled && umamiScripts && <Fragment set:html={umamiScripts} />}
</head>
<body class=" min-h-screen " class:list={[{"is-home": isHomePage, "enable-banner": enableBanner}]}
>
<!-- 页面加载动画 -->
<LoadingOverlay />
<!-- 全局配置载体 -->
<ConfigCarrier />
<!-- 页面顶部渐变高光效果 - 只在full和semifull模式下显示 -->
{shouldShowTopHighlight && <div class="top-gradient-highlight"></div>}
<!-- 页面内容 -->
<slot />
<!-- Music Player - 仅在启用时加载 -->
{musicPlayerConfig.enable && <MusicPlayer client:idle />}
<!-- Pio 看板娘组件 - 如果启用了侧边栏,可能需要调整 z-index -->
{pioConfig.enable && <Pio client:idle />}
<!-- increase the page height during page transition to prevent the scrolling animation from jumping -->
<div id="page-height-extend" class="hidden h-[300vh]"></div>
</body>
</html>
<style is:global define:vars={{
bannerOffset,
'banner-height-home': `${BANNER_HEIGHT_HOME}vh`,
'banner-height': `${BANNER_HEIGHT}vh`,
}}>
@reference "../styles/main.css";
@layer components {
.enable-banner.is-home #banner-wrapper {
@apply h-(--banner-height-home) translate-y-(--banner-height-extend)
}
.enable-banner #banner-wrapper {
@apply h-(--banner-height-home)
}
.enable-banner.is-home #banner {
@apply h-(--banner-height-home) translate-y-0
}
.enable-banner #banner {
@apply h-(--banner-height-home) translate-y-(--bannerOffset)
}
.enable-banner.is-home #main-grid {
translate: 0 var(--banner-height-extend);
}
.enable-banner #top-row {
height: calc(var(--banner-height-home) - 5.5rem);
}
.enable-banner.is-home #sidebar-sticky {
top: calc(1rem - var(--banner-height-extend)) !important;
}
}
</style>
<script>
import {
NAVBAR_HEIGHT,
BANNER_HEIGHT,
BANNER_HEIGHT_HOME,
BANNER_HEIGHT_EXTEND,
} from "@/constants/constants";
import { pathsEqual, url } from "@utils/url";
import { initWallpaperMode } from "@utils/wallpaper";
import { initTheme } from "@utils/theme";
import { initHue } from "@utils/hue";
import { initMarkdownActions } from "@/utils/markdown";
import { initFancybox, cleanupFancybox } from "@utils/fancybox";
import { loadAndInitTranslate } from "@utils/language";
import { setupParticleEffects } from "@/utils/particle";
// 初始化Swup
const setup = () => {
// Swup can update the body for each page visit, but it's after the page transition, causing a delay for banner height change, so use Swup hooks instead to change the height immediately when a link is clicked
window.swup.hooks.on('link:click', () => {
// 简化 navbar 处理逻辑
const bannerEnabled = !!document.getElementById('banner-wrapper')
if (bannerEnabled) {
const navbar = document.getElementById('navbar-wrapper')
if (navbar && document.body.classList.contains('is-home')) {
const threshold = window.innerHeight * (BANNER_HEIGHT / 100) - NAVBAR_HEIGHT
if (document.documentElement.scrollTop >= threshold) {
navbar.classList.add('navbar-hidden')
}
}
}
})
window.swup.hooks.on('content:replace', () => {
// Update main grid layout class name after page transition
const gridClassCarrier = document.getElementById("grid-class-carrier");
if (gridClassCarrier) {
const gridClass = gridClassCarrier.getAttribute("data-grid-class");
const mainGrid = document.getElementById("main-grid");
if (mainGrid && gridClass) {
const currentClasses = mainGrid.className.split(" ");
const newClasses = currentClasses.filter(c => !c.startsWith("lg:grid-cols-") && !c.startsWith("md:grid-cols-") && !c.startsWith("grid-cols-"));
const gridClasses = gridClass.split(" ");
mainGrid.className = [...newClasses, ...gridClasses].join(" ");
}
}
// Reinitialize semifull mode scroll detection after page transition
const navbar = document.getElementById('navbar')
if (navbar) {
const transparentMode = navbar.getAttribute('data-transparent-mode')
if (transparentMode === 'semifull') {
// Re-call the initialization function to rebind scroll events
if (typeof (window as any).initSemifullScrollDetection === 'function') {
(window as any).initSemifullScrollDetection()
}
}
}
})
window.swup.hooks.on('visit:start', (visit: {to: {url: string}}) => {
// Disable scroll protection when a link is clicked to prevent interference with page jump scrolling to top
if ((window as any).scrollProtectionManager && typeof (window as any).scrollProtectionManager.disable === 'function') {
(window as any).scrollProtectionManager.disable();
}
// Change banner height immediately when a link is clicked
const bodyElement = document.querySelector('body')
const isHomePage = pathsEqual(visit.to.url, url('/'))
if (isHomePage) {
bodyElement!.classList.add('is-home');
} else {
bodyElement!.classList.remove('is-home');
}
// Control banner text visibility based on page
const bannerTextOverlay = document.querySelector('.banner-text-overlay')
if (bannerTextOverlay) {
if (isHomePage) {
bannerTextOverlay.classList.remove('hidden')
} else {
bannerTextOverlay.classList.add('hidden')
}
}
// Control navbar transparency based on page
const navbar = document.getElementById('navbar')
if (navbar) {
navbar.setAttribute('data-is-home', isHomePage.toString())
const transparentMode = navbar.getAttribute('data-transparent-mode')
if (transparentMode === 'semifull') {
// Re-call the initialization function to rebind scroll events
if (typeof window.initSemifullScrollDetection === 'function') {
window.initSemifullScrollDetection()
}
}
}
// increase the page height during page transition to prevent the scrolling animation from jumping
const heightExtend = document.getElementById('page-height-extend')
if (heightExtend) {
heightExtend.classList.remove('hidden')
}
});
window.swup.hooks.on('visit:end', (_visit: {to: {url: string}}) => {
setTimeout(() => {
const heightExtend = document.getElementById('page-height-extend')
if (heightExtend) {
heightExtend.classList.add('hidden')
}
}, 300)
});
window.swup.hooks.on('page:view', () => {
// Scroll to top of the page after page transition
window.scrollTo({
top: 0,
behavior: 'smooth'
});
// Check if the current page is a post page, if so, trigger a custom event to initialize the comment system
setTimeout(() => {
if (document.getElementById('tcomment')) {
const pageLoadedEvent = new CustomEvent('twilight:page:loaded', {
detail: {
path: window.location.pathname,
timestamp: Date.now()
}
});
document.dispatchEvent(pageLoadedEvent);
}
}, 300);
// Initialize Fancybox
initFancybox();
});
initFancybox();
window.swup.hooks.on("content:replace", () => {
cleanupFancybox();
},
{ before: true },
)
}
if (window?.swup?.hooks) {
setup()
} else {
document.addEventListener('swup:enable', setup)
}
// 初始化 navbar 元素,用于控制 banner 隐藏
let navbar = document.getElementById('navbar-wrapper')
// 初始化返回顶部按钮元素,用于控制返回顶部按钮的显示与隐藏
let backToTopBtn = document.getElementById('back-to-top-btn');
// 节流函数
function throttle(func: (...args: any[]) => void, limit: number) {
let inThrottle: boolean;
return function(this: any, ...args: any[]) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
}
}
// 滚动事件处理函数用于控制返回顶部按钮和banner隐藏
function scrollFunction() {
const scrollTop = document.documentElement.scrollTop;
const bannerHeight = window.innerHeight * (BANNER_HEIGHT / 100);
const bannerEnabled = !!document.getElementById('banner-wrapper')
// 批量处理DOM操作
requestAnimationFrame(() => {
if (backToTopBtn) {
if (scrollTop > bannerHeight) {
backToTopBtn.classList.remove('hide')
} else {
backToTopBtn.classList.add('hide')
}
}
if (bannerEnabled && navbar) {
const isHome = document.body.classList.contains('is-home');
const currentBannerHeight = isHome ? BANNER_HEIGHT_HOME : BANNER_HEIGHT;
const threshold = window.innerHeight * (currentBannerHeight / 100) - NAVBAR_HEIGHT;
if (scrollTop >= threshold) {
navbar.classList.add('navbar-hidden')
} else {
navbar.classList.remove('navbar-hidden')
}
}
});
}
// 使用节流优化滚动性能
window.onscroll = throttle(scrollFunction, 16); // 约60fps
// 监听窗口 resize 事件,用于更新 banner 高度相关的 CSS 变量
window.onresize = () => {
// calculate the --banner-height-extend, which needs to be a multiple of 4 to avoid blurry text
let offset = Math.floor(window.innerHeight * (BANNER_HEIGHT_EXTEND / 100));
offset = offset - offset % 4;
document.documentElement.style.setProperty('--banner-height-extend', `${offset}px`);
}
// Initialize theme
initTheme();
// Initialize hue
initHue();
// Initialize wallpaper mode
initWallpaperMode();
// 初始化 Markdown 相关操作以防止因 Swup 无刷新跳转的机制限制而导致操作失效
initMarkdownActions();
// Initialize translate.js
loadAndInitTranslate();
// Initialize particle effects
setupParticleEffects();
// Remove is-loading class and trigger initial-animation after page load
if (typeof window !== 'undefined') {
window.addEventListener('load', () => {
const overlay = document.getElementById('loading-overlay');
const enableBanner = document.body.classList.contains('enable-banner');
// 触发 LoadingOverlay 淡出
if (overlay) {
overlay.classList.add('fade-out');
}
// 等待 LoadingOverlay 淡出600ms
setTimeout(() => {
if (overlay) {
overlay.style.display = 'none';
}
if (enableBanner) {
// banner 开始恢复过渡:添加 banner-restoring 类(解除 CSS 中的 height: 0
document.documentElement.classList.add('banner-restoring');
// 等待 banner 恢复过渡完成600ms
setTimeout(() => {
// 触发各容器淡入动画
document.documentElement.classList.remove('is-loading');
document.documentElement.classList.add('show-initial-animation');
setTimeout(() => {
document.documentElement.classList.remove('show-initial-animation');
document.documentElement.classList.remove('banner-restoring');
}, 1200);
}, 600);
} else {
// 非 banner 模式,直接触发淡入
document.documentElement.classList.remove('is-loading');
document.documentElement.classList.add('show-initial-animation');
setTimeout(() => {
document.documentElement.classList.remove('show-initial-animation');
}, 1200);
}
}, 600);
});
}
</script>

248
src/layouts/grid.astro Normal file
View File

@@ -0,0 +1,248 @@
---
import type { MarkdownHeading } from "astro";
import { Icon } from "astro-icon/components";
import { siteConfig } from "@/config";
import {
BANNER_HEIGHT,
MAIN_PANEL_OVERLAPS_BANNER_HEIGHT,
} from "@constants/constants";
import { widgetManager } from "@utils/widget";
import IconifyLoader from "@components/common/iconifyLoader.astro";
import Navbar from "@components/navbar.astro";
import FullscreenWallpaper from "@components/fullscreenWallpaper.astro";
import Banner from "@components/banner.astro";
import Sidebar from "@components/sidebar.astro";
import Footer from "@components/footer.astro";
import TocButton from "@components/tocButton.astro";
import BackToTopButton from "@components/back2TopButton.astro";
import BaseLayout from "./base.astro";
import "@styles/grid.css";
/**
* Wallpaper
*/
interface Props {
title?: string;
banner?: string;
description?: string;
lang?: string;
setOGTypeArticle?: boolean;
postSlug?: string;
headings?: MarkdownHeading[];
}
const {
title,
banner,
description,
lang,
setOGTypeArticle,
postSlug,
headings = [],
} = Astro.props;
const hasBannerCredit = siteConfig.wallpaper.mode === "banner" && siteConfig.wallpaper.banner?.credit?.enable;
const hasBannerLink = !!siteConfig.wallpaper.banner?.credit?.url;
// 检查是否为首页
const isHomePage = Astro.url.pathname === "/" || Astro.url.pathname === "";
// 计算主内容区域位置
const mainPanelTop = siteConfig.wallpaper.mode === "banner"
? `calc(${BANNER_HEIGHT}vh - ${MAIN_PANEL_OVERLAPS_BANNER_HEIGHT}rem)`
: "5.5rem";
// 当banner被禁用时主内容区域应该始终从顶栏下面开始
const finalMainPanelTop = siteConfig.wallpaper.mode === "banner" ? mainPanelTop : "5.5rem";
// 检查是否应该启用半透明效果
const shouldEnableTransparency = siteConfig.wallpaper.mode === "banner" || siteConfig.wallpaper.mode === "fullscreen";
// 为组件添加半透明效果的CSS类
const transparentClass = shouldEnableTransparency ? "wallpaper-transparent" : "";
/**
* Sidebar
*/
// 检查侧边栏是否启用,动态调整网格布局
const {
hasLeftSidebar,
hasRightSidebar,
hasAnyComponents,
gridCols,
leftSidebarClass,
rightSidebarClass,
mainContentClass,
mobileFooterClass,
middleSidebarClass,
} = widgetManager.getGridLayout(headings);
---
<BaseLayout
title={title}
banner={banner}
description={description}
lang={lang}
setOGTypeArticle={setOGTypeArticle}
postSlug={postSlug}
>
<!-- 全局图标加载器 -->
<IconifyLoader />
<!-- Navbar -->
<slot slot="head" name="head" />
<div
id="top-row"
class="z-50 pointer-events-none relative transition-all duration-600 max-w-(--page-width) px-0 md:px-4 mx-auto"
class:list={[""]}
>
<div
id="navbar-wrapper"
class="pointer-events-auto sticky top-0 transition-all"
>
<Navbar />
</div>
</div>
<!-- 全屏壁纸 - 总是渲染但通过CSS控制可见性 -->
<FullscreenWallpaper
config={siteConfig.wallpaper}
class={siteConfig.wallpaper.mode === "fullscreen" ? "" : "hidden"}
/>
<!-- 为全屏壁纸模式添加body类 -->
{
shouldEnableTransparency && (
<script>
document.body.classList.add('wallpaper-transparent');
</script>
)
}
<!-- Banner - 总是渲染但通过CSS控制可见性 -->
<Banner
config={siteConfig.wallpaper}
isHomePage={isHomePage}
class={siteConfig.wallpaper.mode === "banner" ? "" : "hidden"}
/>
<!-- Main content -->
<div
class={`absolute w-full z-30 pointer-events-none transition-all duration-600 ${siteConfig.wallpaper.mode !== "banner" ? "no-banner-layout" : ""} ${transparentClass}`}
style={`top: ${finalMainPanelTop}`}
>
<div
class="relative max-w-(--page-width) mx-auto pointer-events-auto"
>
<div
id="main-grid"
class={`transition-all duration-600 w-full left-0 right-0 grid ${gridCols} grid-rows-[auto] mx-auto gap-4 px-0 md:px-4 ${!hasAnyComponents ? "mobile-no-sidebar" : ""} ${(hasLeftSidebar || hasRightSidebar) ? "mobile-both-sidebar" : ""}`}
>
<!-- Banner image credit -->
{
hasBannerCredit && (
<a
href={siteConfig.wallpaper.banner?.credit?.url}
id="banner-credit"
target="_blank"
rel="noopener"
aria-label="Visit image source"
class:list={[
"group onload-animation-up transition-all absolute flex justify-center items-center rounded-full " +
"px-3 right-4 -top-13 bg-black/60 hover:bg-black/70 h-9",
{
"hover:pr-9 active:bg-black/80":
hasBannerLink,
},
]}
>
<Icon
class="text-white/75 text-[1.25rem] mr-1"
name="material-symbols:copyright-outline-rounded"
/>
<div class="text-white/75 text-xs">
{siteConfig.wallpaper.banner?.credit?.text}
</div>
<Icon
class:list={[
"transition absolute text-[oklch(0.75_0.14_var(--hue))] right-4 text-[0.75rem] opacity-0",
{
"group-hover:opacity-100":
hasBannerLink,
},
]}
name="fa6-solid:arrow-up-right-from-square"
/>
</a>
)
}
<div class="contents lg:contents md:flex md:flex-col md:col-start-1 md:row-start-1 md:gap-4">
{
hasLeftSidebar ? (
<Sidebar
id="left-sidebar"
side="left"
class={`${leftSidebarClass} ${transparentClass} onload-animation-up`}
headings={headings}
/>
) : (
<div id="left-sidebar" class="hidden"></div>
)
}
{
hasRightSidebar ? (
<Sidebar
id="right-sidebar"
side="right"
class={`${rightSidebarClass} ${transparentClass} onload-animation-up`}
headings={headings}
/>
) : (
<div id="right-sidebar" class="hidden"></div>
)
}
</div>
<main
id="swup-container"
class={`${mainContentClass} transition-swup-fade`}
>
<div id="grid-class-carrier" data-grid-class={gridCols} class="hidden"></div>
<div
id="content-wrapper"
class="onload-animation-up transition-leaving"
>
<slot />
<div
class="footer col-span-2 onload-animation-up hidden lg:block transition-swup-fade"
>
<Footer />
</div>
</div>
</main>
<div class={middleSidebarClass}>
<Sidebar
id="mobile-sidebar"
side="middle"
class={`${transparentClass} onload-animation-up`}
headings={headings}
/>
</div>
<div class={mobileFooterClass}>
<Footer />
</div>
</div>
</div>
</div>
<TocButton />
<BackToTopButton />
</BaseLayout>

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

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

112
src/pages/[...menu].astro Normal file
View File

@@ -0,0 +1,112 @@
---
import { Icon } from "astro-icon/components";
import { type NavbarLink } from "@/types/config";
import { navbarConfig } from "@/config";
import { LinkPresets } from "@constants/link-presets";
import { url } from "@utils/url";
import GridLayout from "@layouts/grid.astro";
import BackwardButton from "@components/backwardButton.astro";
export async function getStaticPaths() {
return navbarConfig.links
.filter(link => typeof link !== "number" && link.children && link.children.length > 0)
.map(link => {
const navLink = link as NavbarLink;
// 提取 slug去掉前后的斜杠
const slug = navLink.url.replace(/^\/|\/$/g, '');
return {
params: { menu: slug },
props: { link: navLink }
};
});
}
interface Props {
link: NavbarLink;
}
const { link } = Astro.props;
// 获取子链接,并将 LinkPreset 转换为完整的 NavbarLink 对象
const childrenLinks = link.children || [];
const processedLinks = childrenLinks.map(child => {
if (typeof child === "number") {
return LinkPresets[child];
}
return child;
});
const pageTitle = link.name;
const pageDescription = link.description || "";
---
<GridLayout title={pageTitle} description={pageDescription}>
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full">
<BackwardButton currentPath={Astro.url.pathname} />
<!-- 标题 -->
<header class="mb-8">
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-3">
{pageTitle}
</h1>
{pageDescription && (
<p class="text-neutral-600 dark:text-neutral-400">
{pageDescription}
</p>
)}
</header>
<!-- 链接网格 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{processedLinks.map(child => (
<a
href={child.external ? child.url : url(child.url)}
target={child.external ? "_blank" : null}
class="menu-card group flex flex-col items-center justify-center p-8 rounded-xl border transition-all duration-300"
>
<div class="icon-wrapper w-16 h-16 flex items-center justify-center rounded-full mb-4 transition-all duration-300">
{child.icon && <Icon name={child.icon} class="text-3xl" />}
</div>
<h2 class="text-xl font-bold text-neutral-900 dark:text-neutral-100 group-hover:text-(--primary) transition-colors duration-300 text-center">
{child.name}
</h2>
{child.description && (
<p class="text-sm text-neutral-600 dark:text-neutral-400 text-center mt-2 line-clamp-2">
{child.description}
</p>
)}
</a>
))}
</div>
</div>
</div>
</GridLayout>
<style>
.menu-card {
cursor: pointer;
background-color: none;
border-color: var(--line-divider);
}
.icon-wrapper {
background-color: color-mix(in oklch, var(--primary), transparent 85%);
color: var(--primary);
}
.menu-card:hover {
background-color: color-mix(in oklch, var(--primary), transparent 95%);
border-color: var(--primary);
box-shadow: 0 0 20px color-mix(in oklch, var(--primary), transparent 80%);
translate: 0 -4px;
}
.menu-card:hover .icon-wrapper {
background-color: var(--primary);
color: white;
box-shadow: 0 0 15px color-mix(in oklch, var(--primary), transparent 50%);
}
</style>

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

@@ -0,0 +1,29 @@
---
export const prerender = true;
import type { GetStaticPaths } from "astro";
import { PAGE_SIZE } from "@constants/constants";
import { getSortedPosts } from "@utils/content";
import Pagination from "@components/pagination.astro";
import PostPage from "@components/postPage.astro";
import GridLayout from "@layouts/grid.astro";
export const getStaticPaths = (async ({ paginate }) => {
const allBlogPosts = await getSortedPosts();
return paginate(allBlogPosts, { pageSize: PAGE_SIZE });
}) satisfies GetStaticPaths;
// https://github.com/withastro/astro/issues/6507#issuecomment-1489916992
const { page } = Astro.props;
const len = page.data.length;
---
<GridLayout>
<PostPage page={page}></PostPage>
<Pagination class="mx-auto onload-animation-up" page={page} style={`animation-delay: ${(len)*50}ms`}></Pagination>
</GridLayout>

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

@@ -0,0 +1,35 @@
---
export const prerender = true;
import { getEntry, render } from "astro:content";
import { LinkPresets } from "@constants/link-presets";
import { LinkPreset } from "@/types/config";
import Markdown from "@components/common/markdown.astro";
import GridLayout from "@layouts/grid.astro";
import BackwardButton from "@components/backwardButton.astro";
const pageTitle = LinkPresets[LinkPreset.About].name;
const pageDescription = LinkPresets[LinkPreset.About].description;
const aboutPost = await getEntry("spec", "about");
if (!aboutPost) {
throw new Error("About page content not found");
}
const { Content } = await render(aboutPost);
---
<GridLayout title={pageTitle} description={pageDescription}>
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full ">
<BackwardButton currentPath={Astro.url.pathname} />
<Markdown class="mt-2">
<Content />
</Markdown>
</div>
</div>
</GridLayout>

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

@@ -0,0 +1,131 @@
---
import { LinkPresets } from "@constants/link-presets";
import { LinkPreset } from "@/types/config";
import { sortedAlbums } from "@utils/albums";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import GridLayout from "@layouts/grid.astro";
import BackwardButton from "@components/backwardButton.astro";
const pageTitle = LinkPresets[LinkPreset.Albums].name;
const pageDescription = LinkPresets[LinkPreset.Albums].description;
// 获取所有相册
const albumsData = sortedAlbums.filter(album => album.visible);
---
<GridLayout title={pageTitle} description={pageDescription}>
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full">
<BackwardButton currentPath={Astro.url.pathname} />
<!-- 页面标题 -->
<header class="mb-8">
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-3">
{pageTitle}
</h1>
<p class="text-neutral-600 dark:text-neutral-400">
{pageDescription}
</p>
</header>
<!-- 相册网格 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{albumsData.map(album => (
<article
class="album-card group bg-white dark:bg-neutral-900 rounded-lg overflow-hidden shadow-sm hover:shadow-md transition-all duration-300 border border-neutral-200 dark:border-neutral-800"
>
<a href={`/albums/${album.id}/`} class="block">
<!-- 封面图片 -->
<div class="aspect-4/3 overflow-hidden bg-neutral-100 dark:bg-neutral-700">
<img
src={album.cover}
alt={album.title}
class="w-full h-full object-cover group-hover:scale-105 transition duration-300"
loading="lazy"
/>
</div>
<!-- 相册信息 -->
<div class="p-4">
<h3 class="font-bold text-lg text-neutral-900 dark:text-neutral-100 mb-2 group-hover:text-(--primary) transition-colors">
{album.title}
</h3>
{album.description && (
<p class="text-neutral-600 dark:text-neutral-400 text-sm mb-3 line-clamp-2">
{album.description}
</p>
)}
<!-- 元数据 -->
<div class="flex items-center justify-between text-xs text-neutral-500 dark:text-neutral-500">
<div class="flex items-center gap-4">
<span>{album.photos.length} {album.photos.length > 1 ? i18n(I18nKey.albumsPhotosCount) : i18n(I18nKey.albumsPhotoCount)}</span>
{album.location && (
<span class="flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"></path>
</svg>
{album.location}
</span>
)}
</div>
<time>{new Date(album.date).toLocaleDateString('zh-CN')}</time>
</div>
<!-- 标签 -->
{album.tags && album.tags.length > 0 && (
<div class="flex flex-wrap gap-1 mt-3">
{album.tags.slice(0, 3).map(tag => (
<span class="btn-regular h-6 text-xs px-2 rounded-lg">
{tag}
</span>
))}
{album.tags.length > 3 && (
<span class="btn-regular h-6 text-xs px-2 rounded-lg">
+{album.tags.length - 3}
</span>
)}
</div>
)}
</div>
</a>
</article>
))}
</div>
<!-- 空状态 -->
{albumsData.length === 0 && (
<div class="text-center py-12">
<div class="text-neutral-400 dark:text-neutral-600 mb-4">
<svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
<h3 class="text-lg font-medium text-neutral-900 dark:text-neutral-100 mb-2">
{i18n(I18nKey.albumsEmpty)}
</h3>
<p class="text-neutral-600 dark:text-neutral-400">
{i18n(I18nKey.albumsEmptyDesc)}
</p>
</div>
)}
</div>
</div>
</GridLayout>
<style>
.album-card:hover {
translate: 0 -2px;
}
@keyframes fadeInUp {
from {
opacity: 0;
translate: 0 20px;
}
to {
opacity: 1;
translate: 0 0;
}
}
.album-card {
animation: fadeInUp 0.3s ease-out;
}
</style>

View File

@@ -0,0 +1,139 @@
---
import type { AlbumGroup } from "@utils/albums";
import { sortedAlbums } from "@utils/albums";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import GridLayout from "@layouts/grid.astro";
import BackwardButton from "@components/backwardButton.astro";
export const getStaticPaths = async () => {
return sortedAlbums.filter(album => album.visible).map((album) => ({
params: { id: album.id },
props: { album },
}));
};
interface Props {
album: AlbumGroup;
}
const { album } = Astro.props;
if (!album) {
return Astro.redirect("/404");
}
---
<GridLayout title={album.title} description={album.description || album.title}>
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full">
<BackwardButton href="/albums/" text={i18n(I18nKey.albumsBackToList)} />
<!-- 相册标题信息 -->
<header class="mb-8">
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-3">
{album.title}
</h1>
{album.description && (
<p class="text-neutral-600 dark:text-neutral-400 mb-4">
{album.description}
</p>
)}
<!-- 相册元数据 -->
<div class="flex flex-wrap items-center gap-4 text-sm text-neutral-500 dark:text-neutral-500">
<span>{album.photos.length} {album.photos.length > 1 ? i18n(I18nKey.albumsPhotosCount) : i18n(I18nKey.albumsPhotoCount)}</span>
<time>{new Date(album.date).toLocaleDateString('zh-CN')}</time>
{album.location && (
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"></path>
</svg>
{album.location}
</span>
)}
</div>
<!-- 标签 -->
{album.tags && album.tags.length > 0 && (
<div class="flex flex-wrap gap-2 mt-4">
{album.tags.map(tag => (
<span class="btn-regular h-8 text-sm px-3 rounded-lg">
{tag}
</span>
))}
</div>
)}
</header>
<!-- 照片网格 -->
<div
class={`photo-gallery moment-images ${album.layout === 'masonry' ? 'masonry-layout' : 'grid-layout'}`}
data-layout={album.layout || 'grid'}
data-columns={album.columns || 3}
>
{album.photos.map((photo, index) => (
<figure class="photo-item group relative overflow-hidden rounded-lg">
<a
href={photo.src}
data-fancybox="gallery"
data-caption={photo.alt}
data-no-swup
class="block"
>
<img
src={photo.src}
alt={photo.alt}
class="photo-image w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 cursor-pointer"
loading="lazy"
/>
</a>
</figure>
))}
</div>
</div>
</div>
</GridLayout>
<style>
.grid-layout {
display: grid;
gap: 1rem;
}
.grid-layout[data-columns="2"] {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.grid-layout[data-columns="3"] {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
.grid-layout[data-columns="4"] {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.masonry-layout {
columns: 2;
column-gap: 1rem;
}
@media (min-width: 768px) {
.masonry-layout {
columns: 3;
}
}
@media (min-width: 1280px) {
.masonry-layout[data-columns="4"] {
columns: 4;
}
}
.masonry-layout .photo-item {
break-inside: avoid;
margin-bottom: 1rem;
}
.grid-layout .photo-container {
aspect-ratio: 1;
}
</style>

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

@@ -0,0 +1,360 @@
---
export const prerender = true;
import { LinkPresets } from "@constants/link-presets";
import { LinkPreset } from "@/types/config";
import { siteConfig } from "@/config";
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import ImageWrapper from "@components/common/imageWrapper.astro";
import GridLayout from "@layouts/grid.astro";
import BackwardButton from "@components/backwardButton.astro";
const pageTitle = LinkPresets[LinkPreset.Anime].name;
const pageDescription = LinkPresets[LinkPreset.Anime].description;
// Bangumi API配置
const BANGUMI_USER_ID = siteConfig.bangumi?.userId || "";
const BANGUMI_API_BASE = "https://api.bgm.tv";
// 检查是否配置了有效的 Bangumi 用户 ID
const isBangumiConfigured =
BANGUMI_USER_ID &&
BANGUMI_USER_ID !== "your-bangumi-id" &&
BANGUMI_USER_ID !== "your-user-id";
// 获取单个条目相关人员信息
async function fetchSubjectPersons(subjectId: number) {
if (!isBangumiConfigured) return [];
try {
const response = await fetch(
`${BANGUMI_API_BASE}/v0/subjects/${subjectId}/persons`,
);
const data = await response.json();
return Array.isArray(data) ? data : [];
} catch (error) {
console.error(`Error fetching subject ${subjectId} persons:`, error);
return [];
}
}
// 获取Bangumi收藏列表
async function fetchBangumiCollection(
userId: string,
subjectType: number,
type: number,
) {
if (!isBangumiConfigured) return null;
try {
let allData: any[] = [];
let offset = 0;
const limit = 50; // 每页获取的数量
let hasMore = true;
// 循环获取所有数据
while (hasMore) {
const response = await fetch(
`${BANGUMI_API_BASE}/v0/users/${userId}/collections?subject_type=${subjectType}&type=${type}&limit=${limit}&offset=${offset}`,
);
if (!response.ok) {
throw new Error(`Bangumi API error: ${response.status}`);
}
const data = await response.json();
// 添加当前页数据到总数据中
if (data.data && data.data.length > 0) {
allData = [...allData, ...data.data];
}
if (!data.data || data.data.length < limit) {
hasMore = false;
} else {
offset += limit;
}
// 防止请求过于频繁
await new Promise((resolve) => setTimeout(resolve, 100));
}
return { data: allData };
} catch (error) {
console.error("Error fetching Bangumi data:", error);
return null;
}
}
// 获取Bangumi数据转换为页面所需格式
async function processBangumiData(data: any, status: string) {
if (!data || !data.data) return [];
// 为每个条目获取详细信息
const detailedItems = await Promise.all(
data.data.map(async (item: any) => {
// 获取相关人员信息
const subjectPersons = await fetchSubjectPersons(item.subject_id);
// 获取年份信息
const year = item.subject?.date || "Unknown";
// 获取评分
const rating = item.rate ? Number.parseFloat(item.rate.toFixed(1)) : 0;
// 获取进度信息
const progress = item.ep_status || 0;
const totalEpisodes = item.subject?.eps || progress;
// 从相关人员中获取制作方信息
let studio = "Unknown";
if (Array.isArray(subjectPersons)) {
// 定义筛选优先级顺序
const priorities = ["动画制作", "製作", "制作"];
for (const relation of priorities) {
const match = subjectPersons.find(
(person) => person.relation === relation,
);
if (match?.name) {
studio = match.name;
break;
}
}
}
return {
title: item.subject?.name_cn || item.subject?.name || "Unknown Title",
status: status,
rating: rating,
cover: item.subject?.images?.medium || "/assets/anime/default.webp",
description: (
item.subject?.short_summary ||
item.subject?.name_cn ||
""
).trimStart(),
episodes: `${totalEpisodes} episodes`,
year: year,
genre: item.subject?.tags
? item.subject.tags.slice(0, 3).map((tag: any) => tag.name)
: ["Unknown"],
studio: studio,
link: `https://bgm.tv/subject/${item.subject.id}` || "#",
progress: progress,
totalEpisodes: totalEpisodes,
startDate: item.subject?.date || "",
endDate: item.subject?.date || "",
};
}),
);
return detailedItems;
}
// 获取Bangumi番剧列表
const watchingData = await fetchBangumiCollection(BANGUMI_USER_ID, 2, 3);
const completedData = await fetchBangumiCollection(BANGUMI_USER_ID, 2, 2);
const watchingList = watchingData
? await processBangumiData(watchingData, "watching")
: [];
const completedList = completedData
? await processBangumiData(completedData, "completed")
: [];
const animeList = [...watchingList, ...completedList];
// 获取状态的翻译文本和样式
function getStatusInfo(status: string) {
switch (status) {
case "watching":
return {
text: i18n(I18nKey.animeStatusWatching),
class:
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
icon: "▶",
};
case "completed":
return {
text: i18n(I18nKey.animeStatusCompleted),
class:
"bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
icon: "✓",
};
case "planned":
return {
text: i18n(I18nKey.animeStatusPlanned),
class:
"bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
icon: "⏰",
};
default:
return {
text: status,
class: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
icon: "?",
};
}
}
// 计算统计数据
const stats = {
total: animeList.length,
watching: animeList.filter((anime) => anime.status === "watching").length,
completed: animeList.filter((anime) => anime.status === "completed").length,
avgRating: (() => {
const ratedAnime = animeList.filter((anime) => anime.rating > 0);
if (ratedAnime.length === 0) return "0.0";
return (
ratedAnime.reduce((sum, anime) => sum + anime.rating, 0) /
ratedAnime.length
).toFixed(1);
})(),
};
---
<GridLayout title={pageTitle} description={pageDescription}>
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full">
<BackwardButton currentPath={Astro.url.pathname} />
<!-- 页面标题 -->
<div class="relative w-full mb-8">
<div class="mb-6">
<h1 class="text-4xl font-bold text-black/90 dark:text-white/90 mb-2">{i18n(I18nKey.animeTitle)}</h1>
<p class="text-black/75 dark:text-white/75">{pageDescription}</p>
</div>
<!-- 统计卡片 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="bg-linear-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-lg p-4">
<div class="flex items-center gap-3">
<div class="text-2xl">📊</div>
<div>
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.total}</div>
<div class="text-sm text-blue-600/70 dark:text-blue-400/70">{i18n(I18nKey.animeTotal)}</div>
</div>
</div>
</div>
<div class="bg-linear-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 rounded-lg p-4">
<div class="flex items-center gap-3">
<div class="text-2xl">▶️</div>
<div>
<div class="text-2xl font-bold text-green-600 dark:text-green-400">{stats.watching}</div>
<div class="text-sm text-green-600/70 dark:text-green-400/70">{i18n(I18nKey.animeWatching)}</div>
</div>
</div>
</div>
<div class="bg-linear-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 rounded-lg p-4">
<div class="flex items-center gap-3">
<div class="text-2xl">✅</div>
<div>
<div class="text-2xl font-bold text-purple-600 dark:text-purple-400">{stats.completed}</div>
<div class="text-sm text-purple-600/70 dark:text-purple-400/70">{i18n(I18nKey.animeCompleted)}</div>
</div>
</div>
</div>
<div class="bg-linear-to-br from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-800/20 rounded-lg p-4">
<div class="flex items-center gap-3">
<div class="text-2xl">⭐</div>
<div>
<div class="text-2xl font-bold text-orange-600 dark:text-orange-400">{stats.avgRating}</div>
<div class="text-sm text-orange-600/70 dark:text-orange-400/70">{i18n(I18nKey.animeAvgRating)}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 动漫列表 -->
<div class="mb-8">
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-4">
{i18n(I18nKey.animeList)}
</h2>
{!isBangumiConfigured ? (
<div class="text-center py-12">
<div class="text-5xl mb-4">😢</div>
<h3 class="text-xl font-medium text-black/80 dark:text-white/80 mb-2">
{i18n(I18nKey.animeEmpty)}
</h3>
<p class="text-black/60 dark:text-white/60">
Please configure your Bangumi account in the config file.
</p>
</div>
) : animeList.length > 0 ? (
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-6">
{animeList.map(anime => {
const statusInfo = getStatusInfo(anime.status);
const progressPercent = anime.totalEpisodes > 0 ? (anime.progress / anime.totalEpisodes) * 100 : 0;
return (
<div class="group relative bg-(--card-bg) border border-(--line-divider) rounded-(--radius-large) overflow-hidden transition-all duration-300 hover:shadow-lg hover:scale-[1.02]">
<!-- 封面区域 - 竖屏比例 -->
<div class="relative aspect-2/3 overflow-hidden">
<a href={anime.link} target="_blank" rel="noopener noreferrer" class="block w-full h-full">
<ImageWrapper
src={anime.cover}
alt={anime.title}
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
/>
<div class="absolute inset-0 bg-linear-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-12 h-12 rounded-full bg-white/90 flex items-center justify-center">
<svg class="w-6 h-6 text-gray-800 ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
</div>
</div>
</a>
<!-- 状态标签 -->
<div class={`absolute top-2 left-2 px-2 py-1 rounded-md text-xs font-medium ${statusInfo.class}`}>
<span class="mr-1">{statusInfo.icon}</span>
<span>{statusInfo.text}</span>
</div>
<!-- 评分 -->
<div class="absolute top-2 right-2 bg-black/70 text-white px-2 py-1 rounded-md text-xs font-medium flex items-center gap-1">
<svg class="w-3 h-3 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
<span>{anime.rating}</span>
</div>
<!-- 进度条 - 在封面底部 -->
{anime.status === 'watching' && (
<div class="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/80 to-transparent p-2">
<div class="w-full bg-white/20 rounded-full h-1.5 mb-1">
<div class="bg-linear-to-r from-emerald-400 to-teal-400 h-1.5 rounded-full transition-all duration-300" style={`width: ${progressPercent}%`}></div>
</div>
<div class="text-white text-xs font-medium">
{anime.progress}/{anime.totalEpisodes} ({Math.round(progressPercent)}%)
</div>
</div>
)}
</div>
<!-- 内容区域 - 紧凑设计 -->
<div class="p-3">
<h3 class="text-sm font-bold text-black/90 dark:text-white/90 mb-1 line-clamp-2 leading-tight">{anime.title}</h3>
<p class="text-black/60 dark:text-white/60 text-xs mb-2 line-clamp-2">{anime.description}</p>
<!-- 详细信息 - 更紧凑 -->
<div class="space-y-1 text-xs">
<div class="flex justify-between">
<span class="text-black/50 dark:text-white/50">{i18n(I18nKey.animeYear)}</span>
<span class="text-black/70 dark:text-white/70">{anime.year}</span>
</div>
<div class="flex justify-between">
<span class="text-black/50 dark:text-white/50">{i18n(I18nKey.animeStudio)}</span>
<span class="text-black/70 dark:text-white/70 truncate ml-2">{anime.studio}</span>
</div>
<div class="flex flex-wrap gap-1 mt-2">
{anime.genre.map(g => (
<span class="px-1.5 py-0.5 bg-(--btn-regular-bg) text-black/70 dark:text-white/70 rounded-sm text-xs">{g}</span>
))}
</div>
</div>
</div>
</div>
);
})}
</div>
) : (
<div class="text-center py-12">
<div class="text-5xl mb-4">😢</div>
<h3 class="text-xl font-medium text-black/80 dark:text-white/80 mb-2">
{i18n(I18nKey.animeEmpty)}
</h3>
<p class="text-black/60 dark:text-white/60">
{i18n(I18nKey.animeEmptyBangumi)}
</p>
</div>
)}
</div>
</div>
</div>
</GridLayout>

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

@@ -0,0 +1,35 @@
---
import { LinkPresets } from "@constants/link-presets";
import { LinkPreset } from "@/types/config";
import { getSortedPostsList } from "@utils/content";
import ArchivePanel from "@components/archivePanel.svelte";
import GridLayout from "@layouts/grid.astro";
import BackwardButton from "@components/backwardButton.astro";
const pageTitle = LinkPresets[LinkPreset.Archive].name;
const pageDescription = LinkPresets[LinkPreset.Archive].description;
const sortedPostsList = await getSortedPostsList();
---
<GridLayout title={pageTitle} description={pageDescription}>
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full ">
<BackwardButton currentPath={Astro.url.pathname} />
<!-- 标题 -->
<header class="mb-8">
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-3">
{pageTitle}
</h1>
{pageDescription && (
<p class="text-neutral-600 dark:text-neutral-400">
{pageDescription}
</p>
)}
</header>
<!-- 归档面板 -->
<ArchivePanel sortedPosts={sortedPostsList} client:load></ArchivePanel>
</div>
</div>
</GridLayout>

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

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

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

@@ -0,0 +1,134 @@
import { getImage } from "astro:assets";
import { parse as htmlParser } from "node-html-parser";
import type { APIContext, ImageMetadata } from "astro";
import MarkdownIt from "markdown-it";
import sanitizeHtml from "sanitize-html";
import { siteConfig, profileConfig } from "@/config";
import { getSortedPosts } from "@utils/content";
import { getFileDirFromPath, getPostUrl } from "@utils/url";
const markdownParser = new MarkdownIt();
// get dynamic import of images as a map collection
const imagesGlob = import.meta.glob<{ default: ImageMetadata }>(
"/src/content/**/*.{jpeg,jpg,png,gif,webp}", // include posts and assets
);
export async function GET(context: APIContext) {
if (!context.site) {
throw Error("site not set");
}
// Use the same ordering as site listing (pinned first, then by published desc)
// 过滤掉加密文章和草稿文章
const posts = (await getSortedPosts()).filter((post) => !post.data.encrypted && post.data.draft !== true);
// 创建Atom feed头部
let atomFeed = `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${siteConfig.title}</title>
<subtitle>${siteConfig.subtitle || "No description"}</subtitle>
<link href="${context.site}" rel="alternate" type="text/html"/>
<link href="${new URL("atom.xml", context.site)}" rel="self" type="application/atom+xml"/>
<id>${context.site}</id>
<updated>${new Date().toISOString()}</updated>
<language>${siteConfig.lang}</language>`;
for (const post of posts) {
// convert markdown to html string, ensure post.body is a string
const body = markdownParser.render(String(post.body ?? ""));
// convert html string to DOM-like structure
const html = htmlParser.parse(body);
// hold all img tags in variable images
const images = html.querySelectorAll("img");
for (const img of images) {
const src = img.getAttribute("src");
if (!src) continue;
// Handle content-relative images and convert them to built _astro paths
if (
src.startsWith("./") ||
src.startsWith("../") ||
(!src.startsWith("http") && !src.startsWith("/"))
) {
let importPath: string | null = null;
// derive base directory from real file path to preserve casing
const contentDirRaw = post.filePath
? getFileDirFromPath(post.filePath)
: "src/content/posts";
const contentDir = contentDirRaw.startsWith("src/")
? contentDirRaw
: `src/${contentDirRaw}`;
if (src.startsWith("./")) {
// Path relative to the post file directory
const prefixRemoved = src.slice(2);
importPath = `/${contentDir}/${prefixRemoved}`;
} else if (src.startsWith("../")) {
// Path like ../assets/images/xxx -> relative to /src/content/
const cleaned = src.replace(/^\.\.\//, "");
importPath = `/src/content/${cleaned}`;
} else {
// direct filename (no ./ prefix) - assume it's in the same directory as the post
importPath = `/${contentDir}/${src}`;
}
// import the image module dynamically
const imageMod = await imagesGlob[importPath]?.()?.then(
(res) => res.default,
);
if (imageMod) {
// optimize the image and get the final src URL
const optimizedImg = await getImage({ src: imageMod });
img.setAttribute("src", new URL(optimizedImg.src, context.site).href);
} else {
// log the failed import path
console.log(
`Failed to load image: ${importPath} for post: ${post.id}`,
);
}
} else if (src.startsWith("/")) {
// images starting with `/` are in public dir
img.setAttribute("src", new URL(src, context.site).href);
}
}
// 添加Atom条目
const postUrl = new URL(getPostUrl(post), context.site).href;
const content = sanitizeHtml(html.toString(), {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
});
atomFeed += `
<entry>
<title>${post.data.title}</title>
<link href="${postUrl}" rel="alternate" type="text/html"/>
<id>${postUrl}</id>
<published>${post.data.published.toISOString()}</published>
<updated>${post.data.updated?.toISOString() || post.data.published.toISOString()}</updated>
<summary>${post.data.description || ""}</summary>
<content type="html"><![CDATA[${content}]]></content>
<author>
<name>${profileConfig.name}</name>
</author>`;
// 添加分类标签
if (post.data.category) {
atomFeed += `
<category term="${post.data.category}"></category>`;
}
// 添加标签
atomFeed += `
</entry>`;
}
// 关闭Atom feed
atomFeed += `
</feed>`;
return new Response(atomFeed, {
headers: {
"Content-Type": "application/atom+xml; charset=utf-8",
},
});
}

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

@@ -0,0 +1,260 @@
---
import { LinkPresets } from "@constants/link-presets";
import { LinkPreset } from "@/types/config";
import { siteConfig } from "@/config";
import { sortedMoments } from "@/utils/diary";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import GridLayout from "@layouts/grid.astro";
import BackwardButton from "@components/backwardButton.astro";
const pageTitle = LinkPresets[LinkPreset.Diary].name;
const pageDescription = LinkPresets[LinkPreset.Diary].description;
// 时间格式化函数
function formatTime(dateString: string): string {
var TG = 8;
if (siteConfig.timeZone >= -12 && siteConfig.timeZone <= 12) TG = siteConfig.timeZone;
const timeGap = TG;
const now = new Date();
const date = new Date(dateString);
const diffInMinutes = Math.floor(
(now.getTime() + timeGap*60*60*1000 - date.getTime()) / (1000 * 60),
);
if (diffInMinutes < 60) {
return `${diffInMinutes}${i18n(I18nKey.diaryMinutesAgo)}`;
}
if (diffInMinutes < 1440) {
// 24小时
const hours = Math.floor(diffInMinutes / 60);
return `${hours}${i18n(I18nKey.diaryHoursAgo)}`;
}
const days = Math.floor(diffInMinutes / 1440);
return `${days}${i18n(I18nKey.diaryDaysAgo)}`;
}
---
<GridLayout title={pageTitle} description={pageDescription}>
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
<div class="card-base z-10 px-4 py-4 md:px-6 md:py-5 relative w-full">
<BackwardButton currentPath={Astro.url.pathname} />
<div class="relative max-w-4xl">
<!-- 页面头部 -->
<div class="moments-header mb-6">
<div class="header-content">
<div class="header-info">
<h1 class="moments-title text-xl md:text-2xl lg:text-3xl font-bold text-90 mb-1">{pageTitle}</h1>
<p class="moments-subtitle text-sm md:text-base lg:text-lg text-75">{pageDescription}</p>
</div>
<div class="header-stats">
<div class="stat-item text-center">
<span class="stat-number text-lg md:text-xl lg:text-2xl font-bold text-(--primary)">{sortedMoments.length}</span>
<span class="stat-label text-xs md:text-sm lg:text-base text-75">{i18n(I18nKey.diaryCount)}</span>
</div>
</div>
</div>
</div>
<!-- 短文列表 -->
<div class="moments-timeline">
<div class="timeline-list space-y-4">
{sortedMoments.map(moment => (
<div class="moment-item card-base p-4 md:p-6 lg:p-8 hover:shadow-lg transition-all">
<div class="moment-content">
<p class="moment-text text-sm md:text-base lg:text-lg text-90 leading-relaxed mb-3 md:mb-4">{moment.content}</p>
{moment.images && moment.images.length > 0 && (
<div class="moment-images grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2 md:gap-3 lg:gap-4 mb-3 md:mb-4">
{moment.images.map((image, index) => (
<div class="image-item relative rounded-md overflow-hidden aspect-square cursor-pointer hover:scale-105 transition">
<img
src={image}
alt={i18n(I18nKey.diaryImage)}
class="w-full h-full object-cover"
loading="lazy"
/>
</div>
))}
</div>
)}
</div>
<hr class="moment-divider border-t border-(--line-divider) my-3 md:my-4" />
<div class="moment-footer flex justify-between items-center">
<div class="moment-time flex items-center gap-1.5 text-75 text-xs md:text-sm lg:text-base">
<i class="time-icon text-xs md:text-sm">🕐</i>
<time datetime={moment.date}>
{formatTime(moment.date)}
</time>
</div>
</div>
</div>
))}
</div>
</div>
<!-- 底部提示 -->
<div class="moments-tips text-center mt-6 md:mt-8 lg:mt-10 text-75 text-xs md:text-sm lg:text-base italic">
{i18n(I18nKey.diaryTips)}
</div>
</div>
</div>
</div>
</GridLayout>
<style>
.card-base {
background: var(--card-bg);
border: 1px solid var(--line-divider);
transition: all 0.3s ease;
}
.moments-header {
padding: 1rem;
border-radius: 8px;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark, var(--primary)) 100%);
color: white;
position: relative;
overflow: hidden;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
z-index: 1;
}
.moments-title {
color: white;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.moments-subtitle {
color: rgba(255, 255, 255, 0.9);
}
.stat-number {
color: white;
}
.stat-label {
color: rgba(255, 255, 255, 0.8);
}
.image-item img {
transition: scale 0.3s ease;
}
.image-item:hover img {
scale: 1.05;
}
.action-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
}
.action-btn:hover {
color: var(--primary);
}
/* 让内容中的换行符生效(\n -> 换行) */
.moment-text {
white-space: pre-line;
}
/* 响应式设计 */
/* 桌面端 (大于1280px) */
@media (min-width: 1280px) {
.moments-header {
padding: 1.5rem;
}
.moment-item {
padding: 2rem;
}
.moment-images {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
max-width: 600px;
gap: 1rem;
}
.moment-text {
font-size: 1.1rem;
line-height: 1.8;
}
}
/* 平板竖屏 (768px - 1279px) - 优化显示 */
@media (min-width: 768px) and (max-width: 1279px) {
.moments-header {
padding: 1.25rem;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.moment-item {
padding: 1.5rem;
}
.moment-images {
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
max-width: 500px;
}
.moment-text {
font-size: 1rem;
line-height: 1.7;
}
.moment-footer {
margin-top: 1rem;
}
}
/* 手机端 (小于768px) */
@media (max-width: 768px) {
.moments-header {
padding: 0.75rem;
}
.header-content {
flex-direction: column;
text-align: center;
gap: 0.75rem;
}
.moment-images {
grid-template-columns: repeat(2, 1fr);
}
.moment-footer {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
/* 优化小屏幕显示 */
@media (max-width: 512px) {
.card-base {
margin: 0 -0.5rem;
}
.moment-item {
border-radius: 8px;
}
.moments-header {
border-radius: 6px;
}
}
</style>

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

@@ -0,0 +1,114 @@
---
import { getEntry, render } from "astro:content";
import { LinkPresets } from "@constants/link-presets";
import { LinkPreset } from "@/types/config";
import { friendsData } from "@utils/friends";
import Markdown from "@components/common/markdown.astro";
import GridLayout from "@layouts/grid.astro";
import BackwardButton from "@components/backwardButton.astro";
const pageTitle = LinkPresets[LinkPreset.Friends].name;
const pageDescription = LinkPresets[LinkPreset.Friends].description;
const friendsPost = await getEntry("spec", "friends");
if (!friendsPost) {
throw new Error("friends page content not found");
}
const { Content } = await render(friendsPost);
const items = friendsData;
function shuffleArray(array) {
const newArray = [...array];
for (let i = newArray.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
}
return newArray;
}
const shuffledItems = shuffleArray(items);
---
<GridLayout title={pageTitle} description={pageDescription}>
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full ">
<BackwardButton currentPath={Astro.url.pathname} />
<!-- 标题 -->
<header class="mb-8">
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-3">
{pageTitle}
</h1>
{pageDescription && (
<p class="text-neutral-600 dark:text-neutral-400">
{pageDescription}
</p>
)}
</header>
<!-- 链接网格 -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 my-4">
{shuffledItems.map((item) => (
<a href={item.siteurl} target="_blank" rel="noopener noreferrer" class="menu-card group flex flex-row items-center p-4 rounded-xl border transition-all duration-300 gap-4">
<div class="icon-wrapper w-20 h-20 shrink-0 flex items-center justify-center rounded-full transition-all duration-300 overflow-hidden bg-zinc-200 dark:bg-zinc-900">
<img src={item.imgurl} alt={item.title} class="w-full h-full object-cover transition duration-300 group-hover:scale-110" />
</div>
<div class="flex flex-col justify-center overflow-hidden">
<h2 class="text-lg font-bold text-neutral-900 dark:text-neutral-100 group-hover:text-(--primary) transition-colors duration-300 truncate">
{item.title}
</h2>
<p class="text-sm text-neutral-600 dark:text-neutral-400 line-clamp-1 mt-1">
{item.desc}
</p>
{item.tags && item.tags.length > 0 && (
<div class="flex flex-wrap gap-1.5 mt-2">
{item.tags.slice(0, 2).map((tag) => (
<span class="text-[10px] px-2 py-0.5 rounded-full bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400 border border-neutral-200 dark:border-neutral-700">
{tag}
</span>
))}
{item.tags.length > 2 && (
<span class="text-[10px] px-2 py-0.5 text-neutral-400">
+{item.tags.length - 2}
</span>
)}
</div>
)}
</div>
</a>
))}
</div>
<Markdown class="mt-2">
<Content />
</Markdown>
</div>
</div>
</GridLayout>
<style>
.menu-card {
cursor: pointer;
background-color: none;
border-color: var(--line-divider);
}
.icon-wrapper {
background-color: color-mix(in oklch, var(--primary), transparent 85%);
color: var(--primary);
}
.menu-card:hover {
background-color: color-mix(in oklch, var(--primary), transparent 95%);
border-color: var(--primary);
box-shadow: 0 0 20px color-mix(in oklch, var(--primary), transparent 80%);
translate: 0 -4px;
}
.menu-card:hover .icon-wrapper {
background-color: var(--primary);
color: white;
box-shadow: 0 0 15px color-mix(in oklch, var(--primary), transparent 50%);
}
</style>

View File

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

View File

@@ -0,0 +1,358 @@
---
import { Icon } from "astro-icon/components";
import { render } from "astro:content";
import type { CollectionEntry } from "astro:content";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { siteConfig, profileConfig, postConfig } from "@/config";
import { getSortedPosts } from "@utils/content";
import { formatDateToYYYYMMDD } from "@utils/date";
import { getFileDirFromPath, getPostUrl, getPostUrlBySlug, removeFileExtension} from "@utils/url";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import License from "@components/post/license.astro";
import Markdown from "@components/common/markdown.astro";
import ImageWrapper from "@components/common/imageWrapper.astro";
import Comment from "@components/post/comment.astro";
import PasswordProtection from "@components/common/passwordProtection.astro";
import PostMetadata from "@components/post/postMeta.astro";
import GridLayout from "@layouts/grid.astro";
export async function getStaticPaths() {
const blogEntries = await getSortedPosts();
const paths: {
params: { slug: string };
props: { entry: CollectionEntry<"posts"> };
}[] = [];
for (const entry of blogEntries) {
// 将 id 转换为 slug移除扩展名以匹配路由参数
const slug = removeFileExtension(entry.id);
// 为每篇文章创建默认的 slug 路径
paths.push({
params: { slug },
props: { entry },
});
// 如果文章有自定义固定链接,也创建对应的路径
if (entry.data.routeName) {
// 移除开头的斜杠和结尾的斜杠
// 同时移除可能的 "posts/" 前缀,避免重复
let routeName = entry.data.routeName
.replace(/^\/+/, "")
.replace(/\/+$/, "");
if (routeName.startsWith("posts/")) {
routeName = routeName.replace(/^posts\//, "");
}
paths.push({
params: { slug: routeName },
props: { entry },
});
}
}
return paths;
}
const { entry } = Astro.props;
const { Content, headings } = await render(entry);
const { remarkPluginFrontmatter } = await render(entry);
// 处理加密逻辑
let isEncrypted = !!(entry.data.encrypted && entry.data.password);
dayjs.extend(utc);
const lastModified = dayjs(entry.data.updated || entry.data.published)
.utc()
.format("YYYY-MM-DDTHH:mm:ss");
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: entry.data.title,
description: entry.data.description || entry.data.title,
keywords: entry.data.tags,
author: {
"@type": "Person",
name: profileConfig.name,
url: Astro.site,
},
datePublished: formatDateToYYYYMMDD(entry.data.published),
inLanguage: entry.data.lang
? entry.data.lang.replace("_", "-")
: siteConfig.lang.replace("_", "-"),
// TODO include cover image here
};
---
<GridLayout
banner={entry.data.cover}
title={entry.data.title}
description={entry.data.description}
lang={entry.data.lang}
setOGTypeArticle={true}
postSlug={entry.id}
headings={headings}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={JSON.stringify(jsonLd)}
/>
<div
class="flex w-full rounded-(--radius-large) overflow-hidden relative mb-4"
>
<div
id="post-container"
class:list={[
"card-base z-10 px-6 md:px-9 pt-6 pb-4 relative w-full ",
{},
]}
>
<!-- word count and reading time -->
<div
class="flex flex-row text-black/30 dark:text-white/30 gap-5 mb-3 transition onload-animation-up"
>
<div class="flex flex-row items-center">
<div
class="transition h-6 w-6 rounded-md bg-black/5 dark:bg-white/10 text-black/50 dark:text-white/50 flex items-center justify-center mr-2"
>
<Icon name="material-symbols:notes-rounded" />
</div>
<div class="text-sm">
{remarkPluginFrontmatter.words}
{" " + i18n(I18nKey.wordsCount)}
</div>
</div>
<div class="flex flex-row items-center">
<div
class="transition h-6 w-6 rounded-md bg-black/5 dark:bg-white/10 text-black/50 dark:text-white/50 flex items-center justify-center mr-2"
>
<Icon
name="material-symbols:schedule-outline-rounded"
/>
</div>
<div class="text-sm">
{remarkPluginFrontmatter.minutes}
{
" " +
i18n(
remarkPluginFrontmatter.minutes === 1
? I18nKey.minuteCount
: I18nKey.minutesCount,
)
}
</div>
</div>
</div>
<!-- title -->
<div class="relative onload-animation-up">
<div
data-pagefind-body
data-pagefind-weight="10"
data-pagefind-meta="title"
class="transition w-full block font-bold mb-3
text-3xl md:text-[2.25rem]/[2.75rem]
text-black/90 dark:text-white/90
md:before:w-1 before:h-5 before:rounded-md before:bg-(--primary)
before:absolute before:top-3 before:-left-4.5"
>
{entry.data.title}
</div>
</div>
<!-- metadata -->
<div class="onload-animation-up">
<PostMetadata
className="mb-5"
published={entry.data.published}
updated={entry.data.updated}
tags={entry.data.tags}
category={entry.data.category}
postUrl={getPostUrl(entry)}
/>
{
(!entry.data.cover || (entry.data.cover && !postConfig.showCoverInContent)) && (
<div class="mt-4 border-(--line-divider) border-dashed border-b mb-5" />
)
}
</div>
<!-- always show cover as long as it has one -->
{
entry.data.cover && postConfig.showCoverInContent && (
<>
<div class="mt-4" />
<ImageWrapper
id="post-cover"
src={entry.data.cover}
basePath={getFileDirFromPath(entry.filePath || '')}
class="mb-8 rounded-xl banner-container onload-animation-up"
loading="lazy"
/>
</>
)
}
<PasswordProtection
isEncrypted={isEncrypted}
password={entry.data.password}
>
<Markdown class="mb-6 markdown-content onload-animation-up">
<Content />
</Markdown>
{
postConfig.license.enable && (
<License
title={entry.data.title}
id={entry.id}
pubDate={entry.data.published}
author={entry.data.author}
sourceLink={entry.data.sourceLink}
licenseName={entry.data.licenseName}
licenseUrl={entry.data.licenseUrl}
postUrl={getPostUrl(entry)}
class="mb-6 rounded-xl license-container onload-animation-up"
/>
)
}
</PasswordProtection>
</div>
</div>
<!-- 评论 -->
<Comment post={entry} />
{postConfig.showLastModified && (
<>
<div
id="last-modified"
data-last-modified={lastModified}
data-prefix={i18n(I18nKey.lastModifiedPrefix)}
data-year={i18n(I18nKey.year)}
data-month={i18n(I18nKey.month)}
data-day={i18n(I18nKey.day)}
data-hour={i18n(I18nKey.hour)}
data-minute={i18n(I18nKey.minute)}
data-second={i18n(I18nKey.second)}
style="display: none;"
></div>
<div class="card-base p-6 mb-4">
<script is:inline>
function runtime() {
const lastModifiedElement = document.getElementById('last-modified');
if (!lastModifiedElement) return;
const startDate = new Date(lastModifiedElement.dataset.lastModified);
const currentDate = new Date();
const diff = currentDate - startDate;
const seconds = Math.floor(diff / 1000);
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
const years = Math.floor(days / 365);
const months = Math.floor((days % 365) / 30);
const remainingDays = days % 30;
const prefix = lastModifiedElement.dataset.prefix;
const yearKey = lastModifiedElement.dataset.year;
const monthKey = lastModifiedElement.dataset.month;
const dayKey = lastModifiedElement.dataset.day;
const hourKey = lastModifiedElement.dataset.hour;
const minuteKey = lastModifiedElement.dataset.minute;
const secondKey = lastModifiedElement.dataset.second;
let runtimeString = prefix + ' ';
if (years > 0) {
runtimeString += `${years} ${yearKey} `;
}
if (months > 0) {
runtimeString += `${months} ${monthKey} `;
}
if (remainingDays > 0) {
runtimeString += `${remainingDays} ${dayKey} `;
}
runtimeString += `${hours} ${hourKey} `;
if (minutes < 10) {
runtimeString += `0${minutes} ${minuteKey} `;
} else {
runtimeString += `${minutes} ${minuteKey} `;
}
if (secs < 10) {
runtimeString += `0${secs} ${secondKey}`;
} else {
runtimeString += `${secs} ${secondKey}`;
}
document.getElementById("modifiedtime").innerHTML = runtimeString;
}
setInterval(runtime, 1000);
</script>
<div class="card-base p-1 mb-0.1">
<div class="flex items-center gap-2">
<div class="transition h-9 w-9 rounded-lg overflow-hidden relative flex items-center justify-center mr-0">
<Icon
name="material-symbols:history-rounded"
class="text-4xl text-(--primary) transition-transform group-hover:translate-x-0.5 bg-(--enter-btn-bg) p-2 rounded-md"
/>
</div>
<div class="flex flex-col gap-0.1">
<div id="modifiedtime" class="text-[1.0rem] leading-tight text-black/75 dark:text-white/75"></div>
<p class="text-[0.8rem] leading-tight text-black/75 dark:text-white/75">
{i18n(I18nKey.lastModifiedOutdated)}
</p>
</div>
</div>
</div>
</div>
</>
)}
<div
class="flex flex-col md:flex-row justify-between mb-4 gap-4 overflow-hidden w-full"
>
<a
href={entry.data.nextSlug
? getPostUrlBySlug(entry.data.nextSlug)
: "#"}
class:list={[
"w-full font-bold overflow-hidden active:scale-95",
{ "pointer-events-none": !entry.data.nextSlug },
]}
>
{
entry.data.nextSlug && (
<div class="btn-card rounded-2xl w-full h-15 max-w-full px-4 flex items-center justify-start! gap-4">
<Icon
name="material-symbols:chevron-left-rounded"
class="text-[2rem] text-(--primary)"
/>
<div class="overflow-hidden transition text-ellipsis whitespace-nowrap max-w-[calc(100%-3rem)] text-black/75 dark:text-white/75">
{entry.data.nextTitle}
</div>
</div>
)
}
</a>
<a
href={entry.data.prevSlug
? getPostUrlBySlug(entry.data.prevSlug)
: "#"}
class:list={[
"w-full font-bold overflow-hidden active:scale-95",
{ "pointer-events-none": !entry.data.prevSlug },
]}
>
{
entry.data.prevSlug && (
<div class="btn-card rounded-2xl w-full h-15 max-w-full px-4 flex items-center justify-end! gap-4">
<div class="overflow-hidden transition text-ellipsis whitespace-nowrap max-w-[calc(100%-3rem)] text-black/75 dark:text-white/75">
{entry.data.prevTitle}
</div>
<Icon
name="material-symbols:chevron-right-rounded"
class="text-[2rem] text-(--primary)"
/>
</div>
)
}
</a>
</div>
</GridLayout>

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