mirror of
https://github.com/StepanovPlaton/AboutMe.git
synced 2026-04-06 05:40:52 +04:00
Initial commit
This commit is contained in:
157
src/components/archivePanel.svelte
Normal file
157
src/components/archivePanel.svelte
Normal 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>
|
||||
75
src/components/back2TopButton.astro
Normal file
75
src/components/back2TopButton.astro
Normal 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>
|
||||
45
src/components/backwardButton.astro
Normal file
45
src/components/backwardButton.astro
Normal 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
364
src/components/banner.astro
Normal 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>
|
||||
45
src/components/common/DropdownItem.svelte
Normal file
45
src/components/common/DropdownItem.svelte
Normal 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>
|
||||
23
src/components/common/DropdownPanel.svelte
Normal file
23
src/components/common/DropdownPanel.svelte
Normal 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>
|
||||
43
src/components/common/buttonLink.astro
Normal file
43
src/components/common/buttonLink.astro
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
interface Props {
|
||||
badge?: string;
|
||||
url?: string;
|
||||
label?: string;
|
||||
}
|
||||
const { badge, url, label } = Astro.props;
|
||||
---
|
||||
|
||||
<a href={url} aria-label={label}>
|
||||
<button
|
||||
class:list={`
|
||||
w-full
|
||||
h-10
|
||||
rounded-lg
|
||||
bg-none
|
||||
hover:bg-(--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>
|
||||
14
src/components/common/buttonTag.astro
Normal file
14
src/components/common/buttonTag.astro
Normal 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>
|
||||
181
src/components/common/icon.astro
Normal file
181
src/components/common/icon.astro
Normal file
@@ -0,0 +1,181 @@
|
||||
---
|
||||
// 可靠的图标组件
|
||||
// 提供加载状态管理和错误处理
|
||||
|
||||
export interface Props {
|
||||
icon: string;
|
||||
class?: string;
|
||||
style?: string;
|
||||
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
|
||||
color?: string;
|
||||
fallback?: string; // 备用图标或文本
|
||||
loading?: "lazy" | "eager";
|
||||
}
|
||||
|
||||
const {
|
||||
icon,
|
||||
class: className = "",
|
||||
style = "",
|
||||
size = "md",
|
||||
color,
|
||||
fallback = "●",
|
||||
loading = "lazy",
|
||||
} = Astro.props;
|
||||
|
||||
// 尺寸映射
|
||||
const sizeClasses = {
|
||||
xs: "text-xs",
|
||||
sm: "text-sm",
|
||||
md: "text-base",
|
||||
lg: "text-lg",
|
||||
xl: "text-xl",
|
||||
"2xl": "text-2xl",
|
||||
};
|
||||
|
||||
const sizeClass = sizeClasses[size] || sizeClasses.md;
|
||||
const colorStyle = color ? `color: ${color};` : "";
|
||||
const combinedStyle = `${colorStyle}${style}`;
|
||||
const combinedClass = `${sizeClass} ${className}`.trim();
|
||||
|
||||
// 生成唯一ID
|
||||
const iconId = `icon-${Math.random().toString(36).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>
|
||||
266
src/components/common/iconifyLoader.astro
Normal file
266
src/components/common/iconifyLoader.astro
Normal 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>
|
||||
61
src/components/common/imageWrapper.astro
Normal file
61
src/components/common/imageWrapper.astro
Normal 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>
|
||||
16
src/components/common/markdown.astro
Normal file
16
src/components/common/markdown.astro
Normal 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>
|
||||
285
src/components/common/passwordProtection.astro
Normal file
285
src/components/common/passwordProtection.astro
Normal 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>
|
||||
154
src/components/common/typewriterText.astro
Normal file
154
src/components/common/typewriterText.astro
Normal 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>
|
||||
13
src/components/configCarrier.astro
Normal file
13
src/components/configCarrier.astro
Normal 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>
|
||||
179
src/components/data/projectCard.astro
Normal file
179
src/components/data/projectCard.astro
Normal 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>
|
||||
219
src/components/data/skillCard.astro
Normal file
219
src/components/data/skillCard.astro
Normal 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>
|
||||
372
src/components/data/timelineItem.astro
Normal file
372
src/components/data/timelineItem.astro
Normal 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>
|
||||
)}
|
||||
75
src/components/fontLoader.astro
Normal file
75
src/components/fontLoader.astro
Normal 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')}
|
||||
`} />
|
||||
50
src/components/footer.astro
Normal file
50
src/components/footer.astro
Normal 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>
|
||||
)}
|
||||
© <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> 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>
|
||||
267
src/components/fullscreenWallpaper.astro
Normal file
267
src/components/fullscreenWallpaper.astro
Normal 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>
|
||||
93
src/components/loadingOverlay.astro
Normal file
93
src/components/loadingOverlay.astro
Normal 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>
|
||||
796
src/components/musicPlayer.svelte
Normal file
796
src/components/musicPlayer.svelte
Normal 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
171
src/components/navbar.astro
Normal 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>}
|
||||
139
src/components/navbar/displaySettings.svelte
Normal file
139
src/components/navbar/displaySettings.svelte
Normal 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>
|
||||
106
src/components/navbar/lightDarkSwitch.svelte
Normal file
106
src/components/navbar/lightDarkSwitch.svelte
Normal 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>
|
||||
30
src/components/navbar/navLinks.astro
Normal file
30
src/components/navbar/navLinks.astro
Normal 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>
|
||||
69
src/components/navbar/navMenu.svelte
Normal file
69
src/components/navbar/navMenu.svelte
Normal 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>
|
||||
285
src/components/navbar/search.svelte
Normal file
285
src/components/navbar/search.svelte
Normal 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>
|
||||
153
src/components/navbar/translator.svelte
Normal file
153
src/components/navbar/translator.svelte
Normal 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>
|
||||
106
src/components/navbar/wallpaperSwitch.svelte
Normal file
106
src/components/navbar/wallpaperSwitch.svelte
Normal 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>
|
||||
86
src/components/pagination.astro
Normal file
86
src/components/pagination.astro
Normal 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
110
src/components/pio.svelte
Normal 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>
|
||||
28
src/components/post/comment.astro
Normal file
28
src/components/post/comment.astro
Normal 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>
|
||||
)}
|
||||
53
src/components/post/license.astro
Normal file
53
src/components/post/license.astro
Normal 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>
|
||||
109
src/components/post/postCard.astro
Normal file
109
src/components/post/postCard.astro
Normal 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>
|
||||
163
src/components/post/postMeta.astro
Normal file
163
src/components/post/postMeta.astro
Normal 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>
|
||||
)}
|
||||
98
src/components/post/twikoo.astro
Normal file
98
src/components/post/twikoo.astro
Normal 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>
|
||||
21
src/components/postPage.astro
Normal file
21
src/components/postPage.astro
Normal 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>
|
||||
83
src/components/sidebar.astro
Normal file
83
src/components/sidebar.astro
Normal 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>
|
||||
79
src/components/sidebar/announcement.astro
Normal file
79
src/components/sidebar/announcement.astro
Normal 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>
|
||||
43
src/components/sidebar/categories.astro
Normal file
43
src/components/sidebar/categories.astro
Normal 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>
|
||||
112
src/components/sidebar/profile.astro
Normal file
112
src/components/sidebar/profile.astro
Normal 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>
|
||||
)}
|
||||
19
src/components/sidebar/statistics.astro
Normal file
19
src/components/sidebar/statistics.astro
Normal 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} />
|
||||
572
src/components/sidebar/statistics.svelte
Normal file
572
src/components/sidebar/statistics.svelte
Normal 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>
|
||||
35
src/components/sidebar/tags.astro
Normal file
35
src/components/sidebar/tags.astro
Normal 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>
|
||||
83
src/components/sidebar/toc.astro
Normal file
83
src/components/sidebar/toc.astro
Normal 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>
|
||||
405
src/components/sidebar/toc.ts
Normal file
405
src/components/sidebar/toc.ts
Normal 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);
|
||||
}
|
||||
63
src/components/sidebar/widgetLayout.astro
Normal file
63
src/components/sidebar/widgetLayout.astro
Normal 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>
|
||||
121
src/components/tocButton.astro
Normal file
121
src/components/tocButton.astro
Normal 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>
|
||||
Reference in New Issue
Block a user