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>