init
Some checks failed
Clean ESA Versions on Main / clean-esa-versions (push) Has been cancelled

This commit is contained in:
2026-01-02 00:03:49 +08:00
commit 7b7e32ddd4
348 changed files with 148701 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
---
import { getSortedPosts } from "../utils/content-utils";
import { getPostUrlBySlug } from "../utils/url-utils";
interface Props {
keyword?: string;
}
let posts = await getSortedPosts();
const groups: { year: number; posts: typeof posts }[] = (() => {
const groupedPosts = posts.reduce(
(grouped: { [year: number]: typeof posts }, post) => {
const year = post.data.published.getFullYear();
if (!grouped[year]) {
grouped[year] = [];
}
grouped[year].push(post);
return grouped;
},
{},
);
// convert the object to an array
const groupedPostsArray = Object.keys(groupedPosts).map((key) => ({
year: Number.parseInt(key),
posts: groupedPosts[Number.parseInt(key)],
}));
// sort years by latest first
groupedPostsArray.sort((a, b) => b.year - a.year);
return groupedPostsArray;
})();
function formatDate(date: Date) {
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${month}-${day}`;
}
---
<div class="card-base px-8 py-6">
{
groups.map(group => (
<div>
<div class="flex flex-row w-full items-center h-[3.75rem]">
<div class="w-[15%] md:w-[10%] transition text-2xl font-bold text-right text-75">{group.year}</div>
<div class="w-[15%] md:w-[10%]">
<div class="h-3 w-3 bg-none rounded-full outline outline-[var(--primary)] mx-auto -outline-offset-[2px] z-50 outline-3"></div>
</div>
<div class="w-[70%] md:w-[80%] transition text-left text-50">{group.posts.length} 篇文章</div>
</div>
{group.posts.map(post => (
<a href={getPostUrlBySlug(post.slug)}
aria-label={post.data.title}
class="group btn-plain !block h-10 w-full rounded-lg hover:text-[initial]"
>
<div class="flex flex-row justify-start items-center h-full">
<!-- date -->
<div class="w-[15%] md:w-[10%] transition text-sm text-right text-50">
{formatDate(post.data.published)}
</div>
<!-- dot and line -->
<div class="w-[15%] md:w-[10%] relative dash-line h-full flex items-center">
<div class="transition-all mx-auto w-1 h-1 rounded group-hover:h-5
bg-[oklch(0.5_0.05_var(--hue))] group-hover:bg-[var(--primary)]
outline outline-4 z-50
outline-[var(--card-bg)]
group-hover:outline-[var(--btn-plain-bg-hover)]
group-active:outline-[var(--btn-plain-bg-active)]
"
></div>
</div>
<!-- post title -->
<div class="w-[70%] md:max-w-[65%] md:w-[65%] text-left font-bold
group-hover:translate-x-1 transition-all group-hover:text-[var(--primary)]
text-75 pr-8 whitespace-nowrap overflow-ellipsis overflow-hidden"
>
{post.data.title}
</div>
<!-- tag list -->
<div class="hidden md:block md:w-[15%] text-left text-sm transition
whitespace-nowrap overflow-ellipsis overflow-hidden
text-30"
></div>
</div>
</a>
))}
</div>
))
}
</div>

View File

@@ -0,0 +1,37 @@
---
import type { CollectionEntry } from "astro:content";
import { getPostUrlBySlug } from "../utils/url-utils";
import { formatDateToYYYYMMDD } from "../utils/date-utils";
interface Props {
posts: CollectionEntry<"posts">[];
}
const { posts } = Astro.props;
---
<div class="flex flex-col gap-3">
{posts.map((post) => (
<a
href={getPostUrlBySlug(post.slug)}
class="card-base px-6 py-4 rounded-lg hover:border-[var(--primary)] transition-all group"
>
<div class="flex items-start gap-4">
<div class="text-sm text-50 whitespace-nowrap">
{formatDateToYYYYMMDD(post.data.published)}
</div>
<div class="w-px h-5 bg-[var(--line-divider)]"></div>
<div class="flex-1 min-w-0">
<h3 class="font-bold text-75 group-hover:text-[var(--primary)] transition-all">
{post.data.title}
</h3>
{post.data.description && (
<p class="text-sm text-50 mt-1 line-clamp-1">
{post.data.description}
</p>
)}
</div>
</div>
</a>
))}
</div>

View File

@@ -0,0 +1,62 @@
---
import type { CategoryNode } from "@/types/config";
import { Icon } from "astro-icon/components";
interface Props {
node: CategoryNode;
depth?: number;
}
const { node, depth = 0 } = Astro.props;
---
<div class="relative transition-all">
<div
class:list={[
"flex items-center justify-between rounded-lg hover:bg-[var(--btn-plain-bg-hover)] transition-all group select-none",
depth === 0
? "p-4 bg-[var(--card-bg)] border border-[var(--line-divider)] mb-2"
: "p-2",
]}
>
<a href={node.url} class="flex items-center gap-2 flex-1 min-w-0">
<Icon
name={depth === 0
? "material-symbols:folder-open-rounded"
: "material-symbols:folder-outline-rounded"}
class:list={[
"text-[var(--primary)] shrink-0",
depth === 0 ? "text-2xl" : "text-xl",
]}
/>
<span
class:list={[
"truncate group-hover:text-[var(--primary)] transition-all",
depth === 0 ? "font-bold text-lg text-90" : "font-medium text-75",
]}>{node.name}</span
>
</a>
<span
class="text-sm text-50 bg-black/5 dark:bg-white/10 px-2 py-0.5 rounded shrink-0 ml-2"
>
{node.count}
</span>
</div>
{
node.children && node.children.length > 0 && (
<div
class:list={[
"flex flex-col gap-1",
depth === 0
? "mt-1 mb-3 ml-6 pl-4 border-l-2 border-[var(--line-divider)]"
: "ml-4 pl-4 border-l-2 border-[var(--line-divider)]",
]}
>
{node.children.map((child) => (
<Astro.self node={child} depth={depth + 1} />
))}
</div>
)
}
</div>

View File

@@ -0,0 +1,7 @@
---
import { siteConfig } from "../config";
---
<div id="config-carrier" data-hue={siteConfig.themeColor.hue}>
</div>

170
src/components/Footer.astro Normal file
View File

@@ -0,0 +1,170 @@
---
import { profileConfig } from "../config";
import { url } from "../utils/url-utils";
import { execSync } from "child_process";
const currentYear = new Date().getFullYear();
let commitHash = "unknown";
let buildDate = "unknown";
try {
commitHash = execSync("git rev-parse --short=7 HEAD").toString().trim();
const date = new Date();
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
const seconds = date.getSeconds().toString().padStart(2, "0");
buildDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
} catch (e) {
console.warn("Failed to get git info", e);
}
---
<!--<div class="border-t border-[var(--primary)] mx-16 border-dashed py-8 max-w-[var(--page-width)] flex flex-col items-center justify-center px-6">-->
<div
class="transition border-t border-black/10 dark:border-white/15 my-10 border-dashed mx-4 md:mx-32"
>
</div>
<div class="card-base w-fit mx-auto rounded-xl mt-4 mb-4">
<div class="transition text-50 text-sm text-center p-6">
&copy; <span id="copyright-year">2024 - {currentYear}</span>
<a
class="transition link text-[var(--primary)] font-medium"
target="_blank"
href="https://space.bilibili.com/325903362"
>
{profileConfig.name}
</a>,采用
<a
class="transition link text-[var(--primary)] font-medium"
target="_blank"
href="https://creativecommons.org/licenses/by-nc-sa/4.0/"
>CC BY-NC-SA 4.0</a
> 许可
<br />
<a
class="transition link text-[var(--primary)] font-medium"
target="_blank"
href={url("rss.xml")}>RSS</a
> /
<a
class="transition link text-[var(--primary)] font-medium"
target="_blank"
href={url("sitemap-index.xml")}>网站地图</a
>
<br />
<a
class="transition link text-[var(--primary)] font-medium"
target="_blank"
href="https://astro.build">Astro</a
> 和
<a
class="transition link text-[var(--primary)] font-medium"
target="_blank"
href="https://github.com/saicaca/fuwari">Fuwari</a
> 强力驱动
<br />
本网站代码
<a
class="transition link text-[var(--primary)] font-medium"
target="_blank"
href="https://github.com/afoim/fuwari">已开源</a
>
<a
class="transition link text-black/30 dark:text-white/30 hover:text-[var(--primary)] text-xs ml-1"
target="_blank"
href={`https://github.com/afoim/fuwari/commit/${commitHash}`}
>({commitHash} @ {buildDate})</a
>
<br />
<a
class="transition link text-[var(--primary)] font-medium inline-flex items-center"
href="https://beian.miit.gov.cn/#/Integrated/index"
target="_blank"
><img
alt=""
src="/favicon/foot-icp.png"
class="h-4 mr-1"
/>晋ICP备2025071728号-1</a
>
<br />
<div class="server-info-wrapper flex items-center justify-center">
<span class="server-info text-black/30 dark:text-white/30 text-xs"></span>
<img
class="server-icon h-5 ml-1 hidden bg-white rounded p-0.5"
alt="Server Icon"
/>
</div>
<!-- <a class="transition link text-[var(--primary)] font-medium inline-flex items-center" href="https://beian.mps.gov.cn/#/query/webSearch?code=34010302002608" target="_blank"><img alt="" src="/favicon/foot-ga.png" class="h-4 mr-1">皖公网安备34010302002608号</a>
<br> -->
</div>
</div>
<script>
function updateServerInfo(server) {
const wrappers = document.querySelectorAll(".server-info-wrapper");
wrappers.forEach((wrapper) => {
const serverInfo = wrapper.querySelector(".server-info");
const serverIcon = wrapper.querySelector(".server-icon");
if (serverInfo) {
if (server) {
serverInfo.innerText = `访问节点:${server}`;
if (serverIcon) {
const serverLower = server.toLowerCase();
if (serverLower === "edgeone-pages") {
serverIcon.src = "/cdn/eo.png";
serverIcon.classList.remove("hidden");
serverInfo.classList.add("hidden");
} else if (serverLower === "cloudflare") {
serverIcon.src = "/cdn/cf.svg";
serverIcon.classList.remove("hidden");
serverInfo.classList.add("hidden");
} else if (serverLower === "esa") {
serverIcon.src = "/cdn/esa.svg";
serverIcon.classList.remove("hidden");
serverInfo.classList.add("hidden");
} else {
serverIcon.classList.add("hidden");
serverInfo.classList.remove("hidden");
}
}
} else {
serverInfo.innerText = "访问节点:未知";
serverInfo.classList.remove("hidden");
if (serverIcon) serverIcon.classList.add("hidden");
}
}
});
}
function initServerInfo() {
const isDevMode = localStorage.getItem("dev-mode") === "true";
if (isDevMode) {
const devServer = localStorage.getItem("dev-server");
updateServerInfo(devServer);
} else {
const url = window.location.href;
fetch(url, { method: "HEAD" })
.then((response) => {
const server = response.headers.get("server");
updateServerInfo(server);
})
.catch((error) => {
console.error("Failed to fetch server info:", error);
updateServerInfo(null);
});
}
}
initServerInfo();
document.addEventListener("astro:after-swap", initServerInfo); // Support View Transitions if enabled
// Support Swup if enabled (listen to content replacement)
document.addEventListener("content:replace", initServerInfo);
</script>

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

@@ -0,0 +1,127 @@
---
import { Icon } from "astro-icon/components";
import { navBarConfig, siteConfig } from "../config";
import { LinkPresets } from "../constants/link-presets";
import { LinkPreset, type NavBarLink } from "../types/config";
import { url } from "../utils/url-utils";
import Search from "./Search.svelte";
import DisplaySettings from "./widget/DisplaySettings.svelte";
import NavMenuPanel from "./widget/NavMenuPanel.astro";
const className = Astro.props.class;
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">
<div
class="absolute h-8 left-0 right-0 -top-8 bg-[var(--card-bg)] transition"
>
</div>
<!-- used for onload animation -->
<div
class:list={[
className,
"card-base border border-black/10 dark:border-white/10 !overflow-visible max-w-[var(--page-width)] h-[4.5rem] !rounded-t-none mx-auto flex items-center justify-between px-4",
]}
>
<a
href={url("/")}
class="btn-plain scale-animation rounded-lg h-[3.25rem] px-5 font-bold active:scale-95"
>
<div class="flex flex-row text-[var(--primary)] items-center text-md">
<Icon
name="material-symbols:home-outline-rounded"
class="text-[1.75rem] mb-1 mr-2"
/>
{siteConfig.title}
</div>
</a>
<div class="hidden md:flex">
{
links.map((l) => {
return (
<a
aria-label={l.name}
href={l.external ? l.url : url(l.url)}
target={l.external ? "_blank" : null}
class="btn-plain scale-animation rounded-lg h-11 font-bold px-5 active:scale-95"
>
<div class="flex items-center">
{l.name}
{l.external && (
<Icon
name="fa6-solid:arrow-up-right-from-square"
class="text-[0.875rem] transition -translate-y-[1px] ml-1 text-black/[0.2] dark:text-white/[0.2]"
/>
)}
</div>
</a>
);
})
}
</div>
<div class="flex">
<!--<SearchPanel client:load>-->
<Search client:only="svelte" />
{
!siteConfig.themeColor.fixed && (
<button
aria-label="Display Settings"
class="btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90"
id="display-settings-switch"
>
<Icon
name="material-symbols:palette-outline"
class="text-[1.25rem]"
/>
</button>
)
}
<button
aria-label="Menu"
name="Nav Menu"
class="btn-plain scale-animation rounded-lg w-11 h-11 active:scale-90 md:!hidden"
id="nav-menu-switch"
>
<Icon name="material-symbols:menu-rounded" class="text-[1.25rem]" />
</button>
</div>
<NavMenuPanel links={links} />
<DisplaySettings client:only="svelte" />
</div>
</div>
<script>
function loadButtonScript() {
let settingBtn = document.getElementById("display-settings-switch");
if (settingBtn) {
settingBtn.onclick = function () {
let settingPanel = document.getElementById("display-setting");
if (settingPanel) {
settingPanel.classList.toggle("float-panel-closed");
}
};
}
let menuBtn = document.getElementById("nav-menu-switch");
if (menuBtn) {
menuBtn.onclick = function () {
let menuPanel = document.getElementById("nav-menu-panel");
if (menuPanel) {
menuPanel.classList.toggle("float-panel-closed");
}
};
}
}
loadButtonScript();
</script>

View File

@@ -0,0 +1,112 @@
---
import path from "node:path";
import type { CollectionEntry } from "astro:content";
import { Icon } from "astro-icon/components";
import { getDir } from "../utils/url-utils";
import { formatDateToYYYYMMDD } from "../utils/date-utils";
import PostMetadata from "./PostMeta.astro";
import ImageWrapper from "./misc/ImageWrapper.astro";
import { umamiConfig } from "../config";
interface Props {
class?: string;
entry: CollectionEntry<"posts">;
title: string;
url: string;
published: Date;
updated?: Date;
image: string;
description: string;
draft: boolean;
style: string;
category?: string | string[];
}
const {
entry,
title,
url,
published,
updated,
image,
description,
style,
category,
} = Astro.props;
const isPinned = entry.data.pinned === true;
const className = Astro.props.class;
const hasCover = image !== undefined && image !== null && image !== "";
const coverWidth = "28%";
const { remarkPluginFrontmatter } = await entry.render();
---
<div class:list={["card-base border border-black/10 dark:border-white/10 flex flex-col w-full rounded-[var(--radius-large)] overflow-hidden relative", className]} style={style}>
<div class:list={["pl-9 pr-2 pt-6 pb-6 relative", {"w-[calc(100%_-_52px_-_12px)]": !hasCover, "w-[calc(100%_-_var(--coverWidth)_-_12px)]": hasCover}]}>
<a href={url}
class="transition group w-full block font-bold mb-2 md:mb-3 text-xl md:text-3xl text-90 relative
hover:text-[var(--primary)] dark:hover:text-[var(--primary)]
active:text-[var(--title-active)] dark:active:text-[var(--title-active)]
before:w-1 before:h-5 before:rounded-md before:bg-[var(--primary)]
before:absolute before:top-1 md:before:top-2 before:-left-[1.125rem] before:block
">
{title}
<Icon class="text-[var(--primary)] text-[2rem] transition 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>
<!-- description -->
<div class:list={["transition text-75 mb-3.5 pr-4 line-clamp-1 md:line-clamp-2"]}>
{ description || remarkPluginFrontmatter.excerpt }
</div>
<!-- metadata -->
<PostMetadata published={published} updated={updated} hideUpdateDate={true} hidePublishedDate={true} slug={entry.slug} category={category} class="mb-2 md:mb-4"></PostMetadata>
<!-- word count, read time and page views -->
<div class="text-sm text-black/30 dark:text-white/30 flex gap-4 transition">
<div>{formatDateToYYYYMMDD(published)}</div>
<div>|</div>
<div>{remarkPluginFrontmatter.words} 字</div>
<div>|</div>
<div>{remarkPluginFrontmatter.minutes} 分钟</div>
</div>
</div>
{hasCover && <a href={url} aria-label={title}
class:list={["group",
"max-h-none mx-0 mt-0",
"w-[var(--coverWidth)] absolute top-3 bottom-3 right-3 rounded-xl overflow-hidden active:scale-95"
]} >
<div class="absolute pointer-events-none z-10 w-full h-full group-hover:bg-black/30 group-active:bg-black/50 transition"></div>
<!-- 封面图上的箭头 -->
<div class="absolute pointer-events-none z-20 w-full h-full flex items-center justify-center ">
<Icon name="material-symbols:chevron-right-rounded"
class="transition opacity-0 group-hover:opacity-100 scale-50 group-hover:scale-100 text-white text-5xl">
</Icon>
</div>
<ImageWrapper src={image} basePath={path.join("content/posts/", getDir(entry.id))} alt="Cover Image of the Post"
class="w-full h-full">
</ImageWrapper>
</a>}
{!hasCover &&
<a href={url} aria-label={title} class="!flex btn-regular w-[3.25rem]
absolute right-3 top-3 bottom-3 rounded-xl bg-[var(--enter-btn-bg)]
hover:bg-[var(--enter-btn-bg-hover)] active:bg-[var(--enter-btn-bg-active)] active:scale-95
">
<Icon name="material-symbols:chevron-right-rounded"
class="transition text-[var(--primary)] text-4xl mx-auto">
</Icon>
</a>
}
</div>
<div class="transition border-t-[1px] border-dashed mx-6 border-black/10 dark:border-white/[0.15] last:border-t-0 hidden"></div>
<style define:vars={{coverWidth}}>
</style>

View File

@@ -0,0 +1,136 @@
---
import { Icon } from "astro-icon/components";
import { getDir, url } from "../utils/url-utils";
import { formatDateToYYYYMMDD } from "../utils/date-utils";
import { umamiConfig } from "../config";
import { getPostCategories, getCategoryUrl, parseCategoryPath } from "../utils/category-utils";
interface Props {
class: string;
published: Date;
updated?: Date;
hideUpdateDate?: boolean;
hidePublishedDate?: boolean;
slug?: string;
category?: string | string[];
}
const {
published,
updated,
hideUpdateDate = false,
hidePublishedDate = false,
slug,
category,
} = Astro.props;
const className = Astro.props.class;
// Process category data
const categories = typeof category === "string"
? [category]
: category || [];
---
<div class:list={["flex flex-wrap text-neutral-500 dark:text-neutral-400 items-center gap-4 gap-x-4 gap-y-2", className]}>
<!-- publish date -->
{!hidePublishedDate && (
<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>
)}
<!-- page views & visitors -->
{slug && (
<>
<div class="flex items-center">
<div class="meta-icon">
<Icon name="material-symbols:visibility-outline-rounded" class="text-xl"></Icon>
</div>
<span class="text-50 text-sm font-medium" id={`page-views-${slug}`}>-</span>
</div>
<div class="flex items-center">
<div class="meta-icon">
<Icon name="material-symbols:person" class="text-xl"></Icon>
</div>
<span class="text-50 text-sm font-medium" id={`page-visitors-${slug}`}>-</span>
</div>
</>
)}
<!-- categories -->
{categories.length > 0 && (
<div class="flex items-center flex-wrap gap-2">
<div class="meta-icon">
<Icon name="material-symbols:folder-outline-rounded" class="text-xl"></Icon>
</div>
{categories.map((cat, index) => {
const catPath = parseCategoryPath(cat);
const catUrl = getCategoryUrl(catPath);
const catName = catPath[catPath.length - 1]; // Show last level only
return (
<>
{index > 0 && <span class="text-30">,</span>}
<a
href={catUrl}
class="text-50 text-sm font-medium hover:text-[var(--primary)] transition"
>
{catName}
</a>
</>
);
})}
</div>
)}
</div>
{slug && (
<script define:vars={{ slug, umamiConfig }}>
// 获取访问量统计
async function fetchPageViews() {
if (!umamiConfig.enable) return;
try {
const statsData = await fetchUmamiStats(umamiConfig.baseUrl, umamiConfig.shareId, {
timezone: umamiConfig.timezone,
path: `eq./posts/${slug}/`
});
const pageViews = statsData.pageviews || 0;
const visits = statsData.visitors || 0;
const viewsElement = document.getElementById(`page-views-${slug}`);
const visitorsElement = document.getElementById(`page-visitors-${slug}`);
if (viewsElement) viewsElement.textContent = `${pageViews} 次`;
if (visitorsElement) visitorsElement.textContent = `${visits} 人`;
} catch (error) {
console.error('Error fetching page views:', error);
const viewsElement = document.getElementById(`page-views-${slug}`);
const visitorsElement = document.getElementById(`page-visitors-${slug}`);
if (viewsElement) viewsElement.textContent = '-';
if (visitorsElement) visitorsElement.textContent = '-';
}
}
// 页面加载完成后获取统计数据
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fetchPageViews);
} else {
fetchPageViews();
}
</script>
)}

View File

@@ -0,0 +1,29 @@
---
import type { CollectionEntry } from "astro:content";
import { getPostUrlBySlug } from "@utils/url-utils";
import PostCard from "./PostCard.astro";
const { page } = Astro.props;
let delay = 0;
const interval = 50;
---
<div class="transition flex flex-col rounded-[var(--radius-large)] bg-transparent gap-4 mb-4">
{page.data.map((entry: CollectionEntry<"posts">) => (
<PostCard
entry={entry}
title={entry.data.title}
published={entry.data.published}
updated={entry.data.updated}
url={getPostUrlBySlug(entry.slug)}
image={entry.data.image}
description={entry.data.description}
draft={entry.data.draft}
category={entry.data.category}
class:list="onload-animation"
style={`animation-delay: calc(var(--content-delay) + ${delay++ * interval}ms);`}
></PostCard>
))}
</div>

View File

@@ -0,0 +1,206 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { url } from "@utils/url-utils.ts";
import { onMount } from "svelte";
interface SearchResult {
url: string;
meta: {
title: string;
};
excerpt: string;
urlPath?: string;
}
let keywordDesktop = "";
let keywordMobile = "";
let result: SearchResult[] = [];
let isSearching = false;
let posts: any[] = [];
const togglePanel = () => {
const panel = document.getElementById("search-panel");
panel?.classList.toggle("float-panel-closed");
};
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 highlightText = (text: string, keyword: string): string => {
if (!keyword) return text;
const regex = new RegExp(`(${keyword})`, "gi");
return text.replace(regex, "<mark>$1</mark>");
};
const search = async (keyword: string, isDesktop: boolean): Promise<void> => {
if (!keyword) {
setPanelVisibility(false, isDesktop);
result = [];
return;
}
isSearching = true;
try {
const searchResults = posts
.filter((post) => {
const keywordLower = keyword.toLowerCase();
const searchText =
`${post.title} ${post.description} ${post.content}`.toLowerCase();
const urlPath = `/posts/${post.link}`;
// 支持内容搜索和URL后缀搜索
return searchText.includes(keywordLower) ||
urlPath.toLowerCase().includes(keywordLower) ||
post.link.toLowerCase().includes(keywordLower);
})
.map((post) => {
const contentLower = post.content.toLowerCase();
const keywordLower = keyword.toLowerCase();
const contentIndex = contentLower.indexOf(keywordLower);
let excerpt = '';
if (contentIndex !== -1) {
const start = Math.max(0, contentIndex - 50);
const end = Math.min(post.content.length, contentIndex + 100);
excerpt = post.content.substring(start, end);
if (start > 0) excerpt = '...' + excerpt;
if (end < post.content.length) excerpt = excerpt + '...';
} else {
excerpt = post.description || post.content.substring(0, 150) + '...';
}
return {
url: url(`/posts/${post.link}/`),
meta: {
title: post.title
},
excerpt: highlightText(excerpt, keyword),
urlPath: `/posts/${post.link}`
};
});
result = searchResults;
setPanelVisibility(result.length > 0, isDesktop);
} catch (error) {
console.error("Search error:", error);
result = [];
setPanelVisibility(false, isDesktop);
} finally {
isSearching = false;
}
};
onMount(async () => {
try {
const response = await fetch("/rss.xml");
const text = await response.text();
const parser = new DOMParser();
const xml = parser.parseFromString(text, "text/xml");
const items = xml.querySelectorAll("item");
posts = Array.from(items).map((item) => {
// 尝试多种方式获取content:encoded内容
let content = "";
const contentEncoded =
item.getElementsByTagNameNS("*", "encoded")[0]?.textContent ||
item.querySelector("*|encoded")?.textContent ||
"";
if (contentEncoded) {
content = contentEncoded.replace(/<[^>]*>/g, "");
}
return {
title: item.querySelector("title")?.textContent || "",
description: item.querySelector("description")?.textContent || "",
content: content,
link: item.querySelector("link")?.textContent?.replace(/.*\/posts\/(.*?)\//, "$1") || "",
};
});
} catch (error) {
console.error("Error fetching RSS:", error);
}
});
$: search(keywordDesktop, true);
$: search(keywordMobile, false);
</script>
<!-- search bar for desktop view -->
<div id="search-bar" class="hidden lg:flex transition-all items-center h-11 mr-2 rounded-lg
bg-black/[0.04] hover:bg-black/[0.06] focus-within:bg-black/[0.06]
dark:bg-white/5 dark:hover:bg-white/10 dark:focus-within:bg-white/10
">
<Icon icon="material-symbols:search" class="absolute text-[1.25rem] pointer-events-none ml-3 transition my-auto text-black/30 dark:text-white/30"></Icon>
<input placeholder="搜索" bind:value={keywordDesktop} on:focus={() => search(keywordDesktop, true)}
class="transition-all pl-10 text-sm bg-transparent outline-0
h-full w-40 active:w-60 focus:w-60 text-black/50 dark:text-white/50"
>
</div>
<!-- toggle btn for phone/tablet view -->
<button on:click={togglePanel} aria-label="Search Panel" id="search-switch"
class="btn-plain scale-animation lg:!hidden rounded-lg w-11 h-11 active:scale-90">
<Icon icon="material-symbols:search" class="text-[1.25rem]"></Icon>
</button>
<!-- search panel -->
<div id="search-panel" class="float-panel float-panel-closed search-panel absolute md:w-[30rem]
top-20 left-4 md:left-[unset] right-4 shadow-2xl rounded-2xl p-2">
<!-- search bar inside panel for phone/tablet -->
<div id="search-bar-inside" class="flex relative lg:hidden transition-all items-center h-11 rounded-xl
bg-black/[0.04] hover:bg-black/[0.06] focus-within:bg-black/[0.06]
dark:bg-white/5 dark:hover:bg-white/10 dark:focus-within:bg-white/10
">
<Icon icon="material-symbols:search" class="absolute text-[1.25rem] pointer-events-none ml-3 transition my-auto text-black/30 dark:text-white/30"></Icon>
<input placeholder="Search" bind:value={keywordMobile}
class="pl-10 absolute inset-0 text-sm bg-transparent outline-0
focus:w-60 text-black/50 dark:text-white/50"
>
</div>
<!-- search results -->
{#each result as item}
<a href={item.url}
class="transition first-of-type:mt-2 lg:first-of-type:mt-0 group block
rounded-xl text-lg px-3 py-2 hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)]">
<div class="transition text-90 inline-flex font-bold group-hover:text-[var(--primary)]">
{item.meta.title}<Icon icon="fa6-solid:chevron-right" class="transition text-[0.75rem] translate-x-1 my-auto text-[var(--primary)]"></Icon>
</div>
<div class="transition text-xs text-white mb-1 font-mono">
{item.urlPath}
</div>
<div class="transition text-sm text-50">
{@html item.excerpt}
</div>
</a>
{/each}
</div>
<style>
input:focus {
outline: 0;
}
.search-panel {
background-color: var(--float-panel-bg-opaque);
max-height: calc(100vh - 100px);
overflow-y: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.search-panel::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
</style>

View File

@@ -0,0 +1,49 @@
---
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 hidden lg:block">
<div id="back-to-top-btn" class="back-to-top-btn hide flex items-center rounded-2xl overflow-hidden transition" onclick="backToTop()">
<button aria-label="Back to Top" class="btn-card h-[3.75rem] w-[3.75rem]">
<Icon name="material-symbols:keyboard-arrow-up-rounded" class="mx-auto"></Icon>
</button>
</div>
</div>
<style lang="stylus">
.back-to-top-wrapper
width: 3.75rem
height: 3.75rem
position: absolute
right: 0
top: 0
pointer-events: none
.back-to-top-btn
color: var(--primary)
font-size: 2.25rem
font-weight: bold
border: none
position: fixed
bottom: 10rem
opacity: 1
cursor: pointer
transform: translateX(5rem)
pointer-events: auto
i
font-size: 1.75rem
&.hide
transform: translateX(5rem) scale(0.9)
opacity: 0
pointer-events: none
&:active
transform: translateX(5rem) scale(0.9)
</style>
<script is:raw is:inline>
function backToTop() {
window.scroll({ top: 0, behavior: 'smooth' });
}
</script>

View File

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

View File

@@ -0,0 +1,90 @@
---
import path from "node:path";
interface Props {
id?: string;
src: string;
class?: string;
alt?: string;
position?: string;
basePath?: string;
}
import { Image } from "astro:assets";
import { url } from "../../utils/url-utils";
import { imageFallbackConfig, siteConfig } from "../../config";
const { id, src, alt, position = "center", basePath = "/" } = Astro.props;
const className = Astro.props.class;
const isLocal = !(
src.startsWith("/") ||
src.startsWith("http") ||
src.startsWith("https") ||
src.startsWith("data:")
);
const isPublic = src.startsWith("/");
// TODO temporary workaround for images dynamic import
// https://github.com/withastro/astro/issues/3373
// biome-ignore lint/suspicious/noImplicitAnyLet: <explanation>
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/")}`,
);
}
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 image-wrapper']} style={`--theme-hue: ${siteConfig.themeColor.hue}`}>
<!-- 加载条 -->
<div class="loading-bar absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-32 h-1 bg-gray-200 dark:bg-gray-700 z-10 rounded-full overflow-hidden">
<div class="loading-progress h-full w-8 bg-[oklch(0.70_0.14_var(--theme-hue))] animate-loading-progress rounded-full"></div>
</div>
<!-- 图片内容 -->
{isLocal && img && <Image src={img} alt={alt || ""} class={`${imageClass} image-content opacity-0 transition-opacity duration-500`} style={imageStyle} onload="this.style.opacity='1'; this.parentElement.querySelector('.loading-bar').style.opacity='0';"/>}
{!isLocal && (
imageFallbackConfig.enable && src.includes(imageFallbackConfig.originalDomain) ?
<img src={isPublic ? url(src) : src} alt={alt || ""} class={`${imageClass} image-content opacity-0 transition-opacity duration-500`} style={imageStyle} onload="this.style.opacity='1'; this.parentElement.querySelector('.loading-bar').style.opacity='0';" onerror={`this.onerror=null; this.src='${(isPublic ? url(src) : src).replace(imageFallbackConfig.originalDomain, imageFallbackConfig.fallbackDomain)}';`}/> :
<img src={isPublic ? url(src) : src} alt={alt || ""} class={`${imageClass} image-content opacity-0 transition-opacity duration-500`} style={imageStyle} onload="this.style.opacity='1'; this.parentElement.querySelector('.loading-bar').style.opacity='0';"/>
)}
</div>
<style>
.loading-bar {
transition: opacity 0.5s ease-out;
}
@keyframes loading-progress {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}
.animate-loading-progress {
animation: loading-progress 1.5s ease-in-out infinite;
}
.image-content {
transition: transform 0.3s ease-out, opacity 0.5s ease-out;
}
.image-wrapper:hover .image-content {
transform: scale(1.05);
}
</style>

View File

@@ -0,0 +1,60 @@
---
import { Icon } from "astro-icon/components";
import { licenseConfig, profileConfig } from "../../config";
import { formatDateToYYYYMMDD } from "../../utils/date-utils";
interface Props {
title: string;
slug: string;
pubDate: Date;
class: string;
}
const { title, pubDate } = Astro.props;
const className = Astro.props.class;
const profileConf = profileConfig;
const licenseConf = licenseConfig;
const postUrl = decodeURIComponent(Astro.url.toString());
---
<div class=`relative transition overflow-hidden bg-[var(--license-block-bg)] py-5 px-6 ${className}`>
<div class="transition font-bold text-black/75 dark:text-white/75">
{title}
</div>
<a id="current-link" style="display:none;" class="link text-[var(--primary)]"></a>
<script>
const currentLink = document.getElementById('current-link');
if (currentLink) {
console.log(`[license] old url: ${window.location.href}`);
const url = new URL(window.location.href);
url.search = '';
const address = url.toString();
console.log(`[license] new url: ${address}`);
currentLink.textContent = address; // 显示文本 = 当前 URL
currentLink.href = address; // 点击跳转 = 当前 URL
currentLink.style.display = 'inline'; // 显示出来
}
</script>
<div class="flex gap-6 mt-2">
<div>
<div class="transition text-black/30 dark:text-white/30 text-sm">作者</div>
<div class="transition text-black/75 dark:text-white/75 line-clamp-2">{profileConf.name}</div>
</div>
<div>
<div class="transition text-black/30 dark:text-white/30 text-sm">发布于</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">许可协议</div>
<a href={licenseConf.url} target="_blank" class="link text-[var(--primary)] line-clamp-2">{licenseConf.name}</a>
</div>
</div>
<Icon name="fa6-brands:creative-commons" class="transition text-[15rem] absolute pointer-events-none right-6 top-1/2 -translate-y-1/2 text-black/5 dark:text-white/5"></Icon>
</div>

View File

@@ -0,0 +1,42 @@
---
import "@fontsource-variable/jetbrains-mono";
import "@fontsource-variable/jetbrains-mono/wght-italic.css";
interface Props {
class: string;
}
const className = Astro.props.class;
---
<div data-pagefind-body class=`prose dark:prose-invert prose-base !max-w-none custom-md ${className}`>
<!--<div class="prose dark:prose-invert max-w-none custom-md">-->
<!--<div class="max-w-none custom-md">-->
<slot/>
</div>
<script>
document.addEventListener("click", function (e: MouseEvent) {
const target = e.target as Element | null;
if (target && target.classList.contains("copy-btn")) {
const preEle = target.closest("pre");
const codeEle = preEle?.querySelector("code");
const code = Array.from(codeEle?.querySelectorAll(".code:not(summary *)") ?? [])
.map(el => el.textContent)
.map(t => t === "\n" ? "" : t)
.join("\n");
navigator.clipboard.writeText(code);
const timeoutId = target.getAttribute("data-timeout-id");
if (timeoutId) {
clearTimeout(parseInt(timeoutId));
}
target.classList.add("success");
// 设置新的timeout并保存ID到按钮的自定义属性中
const newTimeoutId = setTimeout(() => {
target.classList.remove("success");
}, 1000);
target.setAttribute("data-timeout-id", newTimeoutId.toString());
}
});
</script>

View File

@@ -0,0 +1,61 @@
---
import type { CategoryNode } from "@/types/config";
import { Icon } from "astro-icon/components";
interface Props {
category: CategoryNode;
depth?: number;
}
const { category, depth = 0 } = Astro.props;
const hasChildren = category.children && category.children.length > 0;
---
<div class="category-drawer relative">
<div
class="flex items-center justify-between px-3 py-2 rounded hover:bg-[var(--btn-plain-bg-hover)] transition-all group w-full"
>
<div class="flex items-center gap-2 overflow-hidden w-full">
{
hasChildren ? (
<button
class="drawer-toggle text-50 hover:text-[var(--primary)] transition-colors cursor-pointer shrink-0 flex items-center justify-center h-6 w-6 rounded-md hover:bg-black/5 dark:hover:bg-white/10"
aria-label="Toggle children"
>
<Icon
name="material-symbols:chevron-right-rounded"
class="text-xl transition-transform duration-200"
/>
</button>
) : (
<span class="w-6 shrink-0" />
)
}
<a
href={category.url}
class="text-75 group-hover:text-[var(--primary)] transition-all truncate flex-1"
>
{category.name}
</a>
</div>
<span
class="text-sm text-50 bg-black/5 dark:bg-white/10 px-2 py-0.5 rounded shrink-0 ml-2"
>
{category.count}
</span>
</div>
{
hasChildren && (
<div class="drawer-content grid grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-out pl-3 ml-3 border-l border-[var(--line-divider)]">
<div class="overflow-hidden">
{category.children.map((child) => (
<Astro.self category={child} depth={depth + 1} />
))}
</div>
</div>
)
}
</div>

View File

@@ -0,0 +1,85 @@
---
import { getSortedPosts } from "@utils/content-utils";
import { buildCategoryTree } from "@utils/category-utils";
import { Icon } from "astro-icon/components";
import CategoryDrawer from "./CategoryDrawer.astro";
const allPosts = await getSortedPosts();
const categoryTree = await buildCategoryTree(allPosts);
function getTotalCount(cat: any): number {
let count = cat.count;
if (cat.children) {
for (const child of cat.children) {
count += getTotalCount(child);
}
}
return count;
}
// Get only top-level categories, sort by total post count (including children), max 15
const topCategories = Object.values(categoryTree)
.filter((cat) => cat.path.length === 1)
.map((cat) => ({ ...cat, totalCount: getTotalCount(cat) }))
.sort((a, b) => b.totalCount - a.totalCount)
.slice(0, 15);
---
<div class="card-base px-6 py-5">
<div class="flex items-center gap-2 mb-4">
<Icon
name="material-symbols:folder-outline-rounded"
class="text-[var(--primary)]"
/>
<h2 class="text-lg font-bold text-90">分类</h2>
</div>
<div class="flex flex-col gap-0">
{topCategories.map((category) => <CategoryDrawer category={category} />)}
</div>
<a
href="/category/"
class="mt-4 text-center text-sm text-[var(--primary)] hover:underline block"
>
查看全部分类 &rarr;
</a>
</div>
<script>
function setupCategoryDrawer() {
const toggles = document.querySelectorAll(".drawer-toggle");
toggles.forEach((toggle) => {
if (toggle.hasAttribute("data-bound")) return;
toggle.setAttribute("data-bound", "true");
toggle.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const drawer = toggle.closest(".category-drawer");
const content = drawer.querySelector(".drawer-content");
const icon = toggle.querySelector("svg");
if (!content || !icon) return;
const isExpanded = content.style.gridTemplateRows === "1fr";
if (isExpanded) {
content.style.gridTemplateRows = "0fr";
icon.style.transform = "rotate(0deg)";
} else {
content.style.gridTemplateRows = "1fr";
icon.style.transform = "rotate(90deg)";
}
});
});
}
setupCategoryDrawer();
// Re-run on Swup navigation
if (window.swup) {
window.swup.hooks.on("content:replace", setupCategoryDrawer);
}
</script>

View File

@@ -0,0 +1,331 @@
<script lang="ts">
import { onMount } from "svelte";
import Icon from "@iconify/svelte";
import {
getDefaultHue,
getHue,
setHue,
getStoredTheme,
setTheme,
getRainbowMode,
setRainbowMode,
getRainbowSpeed,
setRainbowSpeed,
getBgBlur,
setBgBlur,
setBgHueRotate,
getHideBg,
setHideBg,
getDevMode,
setDevMode,
getDevServer,
setDevServer,
} from "@utils/setting-utils";
import { AUTO_MODE, DARK_MODE, LIGHT_MODE } from "@constants/constants";
let hue = getHue();
let theme = getStoredTheme();
let isRainbowMode = getRainbowMode();
let rainbowSpeed = getRainbowSpeed();
let bgBlur = getBgBlur();
let hideBg = getHideBg();
let isDevMode = getDevMode();
let devServer = getDevServer();
let animationId: number;
let lastUpdate = 0;
let rainbowHue = 0; // Independent hue for background rotation
const defaultHue = getDefaultHue();
function resetHue() {
hue = getDefaultHue();
}
$: if ((hue || hue === 0) && !isRainbowMode) {
setHue(hue);
}
$: {
setBgBlur(bgBlur);
}
function switchTheme(newTheme: string) {
theme = newTheme;
setTheme(newTheme);
}
function updateRainbow() {
if (!isRainbowMode) return;
hue = (hue + rainbowSpeed * 0.05) % 360;
setHue(hue, false);
animationId = requestAnimationFrame(updateRainbow);
}
function toggleRainbow() {
isRainbowMode = !isRainbowMode;
setRainbowMode(isRainbowMode);
if (isRainbowMode) {
lastUpdate = performance.now();
rainbowHue = 0; // Reset rotation start
animationId = requestAnimationFrame(updateRainbow);
} else {
cancelAnimationFrame(animationId);
// Reset background rotation to 0 when stopped
setBgHueRotate(0);
}
}
function toggleHideBg() {
hideBg = !hideBg;
setHideBg(hideBg);
}
function toggleDevMode() {
isDevMode = !isDevMode;
setDevMode(isDevMode);
}
function onDevServerChange() {
setDevServer(devServer);
}
function onSpeedChange() {
setRainbowSpeed(rainbowSpeed);
}
onMount(() => {
if (isRainbowMode) {
updateRainbow();
}
return () => {
if (animationId) cancelAnimationFrame(animationId);
}
});
</script>
<div id="display-setting" class="float-panel float-panel-closed absolute transition-all w-80 right-4 px-4 py-4">
<div class="flex flex-row gap-2 mb-3 items-center justify-between">
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
before:absolute before:-left-3 before:top-[0.33rem]"
>
主题模式
</div>
<div class="flex gap-1">
<button aria-label="Light Mode"
class="w-10 h-7 rounded-md transition flex items-center justify-center active:scale-90
{theme === LIGHT_MODE ? 'bg-[var(--primary)] text-white' : 'bg-[var(--btn-regular-bg)] text-[var(--btn-content)] hover:bg-[var(--btn-regular-bg-hover)]'}"
on:click={() => switchTheme(LIGHT_MODE)}
>
<Icon icon="material-symbols:wb-sunny-rounded" class="text-[1.1rem]"></Icon>
</button>
<button aria-label="Dark Mode"
class="w-10 h-7 rounded-md transition flex items-center justify-center active:scale-90
{theme === DARK_MODE ? 'bg-[var(--primary)] text-white' : 'bg-[var(--btn-regular-bg)] text-[var(--btn-content)] hover:bg-[var(--btn-regular-bg-hover)]'}"
on:click={() => switchTheme(DARK_MODE)}
>
<Icon icon="material-symbols:dark-mode-rounded" class="text-[1.1rem]"></Icon>
</button>
<button aria-label="Auto Mode"
class="w-10 h-7 rounded-md transition flex items-center justify-center active:scale-90
{theme === AUTO_MODE ? 'bg-[var(--primary)] text-white' : 'bg-[var(--btn-regular-bg)] text-[var(--btn-content)] hover:bg-[var(--btn-regular-bg-hover)]'}"
on:click={() => switchTheme(AUTO_MODE)}
>
<Icon icon="material-symbols:hdr-auto-rounded" class="text-[1.1rem]"></Icon>
</button>
</div>
</div>
<div class="flex flex-row gap-2 mb-3 items-center justify-between">
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
before:absolute before:-left-3 before:top-[0.33rem]"
>
主题色彩
<button aria-label="Reset to Default" class="btn-regular w-7 h-7 rounded-md active:scale-90"
class:opacity-0={hue === defaultHue} class:pointer-events-none={hue === defaultHue} on:click={resetHue}>
<div class="text-[var(--btn-content)]">
<Icon icon="fa6-solid:arrow-rotate-left" class="text-[0.875rem]"></Icon>
</div>
</button>
</div>
<div class="flex gap-1">
<input aria-label="Hue Value" id="hueValue" type="number" min="0" max="360" value={Math.round(hue)} on:input={(e) => hue = e.currentTarget.valueAsNumber} disabled={isRainbowMode}
class="transition bg-[var(--btn-regular-bg)] w-12 h-7 rounded-md text-center font-bold text-sm text-[var(--btn-content)] outline-none"
/>
</div>
</div>
<div class="w-full h-6 px-1 bg-[oklch(0.80_0.10_0)] dark:bg-[oklch(0.70_0.10_0)] rounded select-none mb-3">
<input aria-label="主题色彩" type="range" min="0" max="360" bind:value={hue} disabled={isRainbowMode}
class="slider" id="colorSlider" step="1" style="width: 100%">
</div>
<div class="flex flex-row gap-2 mb-3 items-center justify-between">
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
before:absolute before:-left-3 before:top-[0.33rem]"
>
禁用背景
</div>
<input type="checkbox" class="toggle-switch" checked={hideBg} on:change={toggleHideBg} />
</div>
<div class="flex flex-row gap-2 mb-3 items-center justify-between">
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
before:absolute before:-left-3 before:top-[0.33rem]"
>
彩虹模式
</div>
<input type="checkbox" class="toggle-switch" checked={isRainbowMode} on:change={toggleRainbow} />
</div>
{#if isRainbowMode}
<div class="flex flex-row gap-2 mb-3 items-center justify-between transition-all" >
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
before:absolute before:-left-3 before:top-[0.33rem]"
>
变换速率
</div>
<div class="flex gap-1">
<div class="transition bg-[var(--btn-regular-bg)] w-10 h-7 rounded-md flex justify-center
font-bold text-sm items-center text-[var(--btn-content)]">
{rainbowSpeed}
</div>
</div>
</div>
<div class="w-full h-6 px-1 bg-[var(--btn-regular-bg)] rounded select-none">
<input aria-label="变换速率" type="range" min="1" max="100" bind:value={rainbowSpeed} on:change={onSpeedChange}
class="slider" step="1" style="width: 100%">
</div>
{/if}
<div class="flex flex-row gap-2 mb-3 mt-3 items-center justify-between">
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
before:absolute before:-left-3 before:top-[0.33rem]"
>
背景模糊
</div>
<div class="flex gap-1">
<div class="transition bg-[var(--btn-regular-bg)] w-10 h-7 rounded-md flex justify-center
font-bold text-sm items-center text-[var(--btn-content)]">
{bgBlur}px
</div>
</div>
</div>
<div class="w-full h-6 px-1 bg-[var(--btn-regular-bg)] rounded select-none">
<input aria-label="背景模糊" type="range" min="0" max="20" bind:value={bgBlur}
class="slider" step="1" style="width: 100%">
</div>
<div class="flex flex-row gap-2 mb-3 mt-3 items-center justify-between">
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
before:absolute before:-left-3 before:top-[0.33rem]"
>
开发模式
</div>
<input type="checkbox" class="toggle-switch" checked={isDevMode} on:change={toggleDevMode} />
</div>
{#if isDevMode}
<div class="flex flex-row gap-2 mb-3 items-center justify-between transition-all" >
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
before:absolute before:-left-3 before:top-[0.33rem]"
>
Server
</div>
<div class="flex gap-1">
<input aria-label="Server Value" type="text" bind:value={devServer} on:input={onDevServerChange}
class="transition bg-[var(--btn-regular-bg)] w-32 h-7 rounded-md text-center font-bold text-sm text-[var(--btn-content)] outline-none"
/>
</div>
</div>
{/if}
</div>
<style lang="stylus">
#display-setting
input[type="number"]
-moz-appearance textfield
&::-webkit-inner-spin-button
&::-webkit-outer-spin-button
-webkit-appearance none
margin 0
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)
.toggle-switch
appearance none
width 3rem
height 1.5rem
background var(--btn-regular-bg)
border-radius 999px
position relative
cursor pointer
transition background 0.3s
&::after
content ''
position absolute
top 0.25rem
left 0.25rem
width 1rem
height 1rem
background var(--btn-content)
border-radius 50%
transition transform 0.3s
&:checked
background var(--primary)
&::after
transform translateX(1.5rem)
background white
</style>

View File

@@ -0,0 +1,32 @@
---
import { Icon } from "astro-icon/components";
import { type NavBarLink } from "../../types/config";
import { url } from "../../utils/url-utils";
interface Props {
links: NavBarLink[];
}
const links = Astro.props.links;
---
<div id="nav-menu-panel" class:list={["float-panel float-panel-closed absolute transition-all fixed right-4 px-2 py-2"]}>
{links.map((link) => (
<a href={link.external ? link.url : url(link.url)} class="group flex justify-between items-center py-2 pl-3 pr-1 rounded-lg gap-8
hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)] transition
"
target={link.external ? "_blank" : null}
>
<div class="transition text-black/75 dark:text-white/75 font-bold group-hover:text-[var(--primary)] group-active:text-[var(--primary)]">
{link.name}
</div>
{!link.external && <Icon name="material-symbols:chevron-right-rounded"
class="transition text-[1.25rem] text-[var(--primary)]"
>
</Icon>}
{link.external && <Icon name="fa6-solid:arrow-up-right-from-square"
class="transition text-[0.75rem] text-black/25 dark:text-white/25 -translate-x-1"
>
</Icon>}
</a>
))}
</div>

View File

@@ -0,0 +1,93 @@
---
import { Icon } from "astro-icon/components";
import { profileConfig, umamiConfig, siteConfig } from "../../config";
const config = profileConfig;
---
<div class="card-base p-3 border border-black/10 dark:border-white/10">
<div class="relative mx-auto mt-1 lg:mx-0 lg:mt-0 mb-3 max-w-[12rem] lg:max-w-none rounded-xl overflow-hidden" style={`--theme-hue: ${siteConfig.themeColor.hue}`}>
<div class="loading-bar absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-32 h-1 bg-gray-200 dark:bg-gray-700 z-10 rounded-full overflow-hidden">
<div class="loading-progress h-full w-8 bg-[oklch(0.70_0.14_var(--theme-hue))] animate-loading-progress rounded-full"></div>
</div>
<img src={config.avatar} alt="Profile Image of the Author" class="w-full h-full object-cover opacity-0 transition-opacity duration-500" onload="this.style.opacity='1'; this.parentElement.querySelector('.loading-bar').style.opacity='0';"/>
</div>
<div class="px-2">
<div class="font-bold text-xl text-center mb-1 dark:text-neutral-50 transition">{config.name}</div>
<div class="h-1 w-5 bg-[var(--primary)] mx-auto rounded-full mb-2 transition"></div>
<div class="text-center text-neutral-400 mb-2.5 transition">{config.bio}</div>
<div class="flex flex-wrap gap-2 justify-center mb-1">
{config.links.length > 1 && config.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>
)}
{config.links.length == 1 && <a rel="me" aria-label={config.links[0].name} href={config.links[0].url} target="_blank"
class="btn-regular rounded-lg h-10 gap-2 px-3 font-bold active:scale-95">
<Icon name={config.links[0].icon} class="text-[1.5rem]"></Icon>
{config.links[0].name}
</a>}
</div>
<!-- 全站访问量统计 -->
<div class="grid grid-cols-2 mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700">
<div class="text-center">
<div class="text-xs text-neutral-500 mb-1 flex items-center justify-center gap-1">
<Icon name="material-symbols:visibility-outline" class="text-base"></Icon>
<span class="text-xs">访问量</span>
</div>
<div id="site-views" class="font-bold text-lg text-neutral-700 dark:text-neutral-300">-</div>
</div>
<div class="text-center border-l border-neutral-200 dark:border-neutral-700">
<div class="text-xs text-neutral-500 mb-1 flex items-center justify-center gap-1">
<Icon name="material-symbols:person" class="text-base"></Icon>
<span class="text-xs">访客数</span>
</div>
<div id="site-visitors" class="font-bold text-lg text-neutral-700 dark:text-neutral-300">-</div>
</div>
</div>
</div>
</div>
<style>
.loading-bar {
transition: opacity 0.5s ease-out;
}
@keyframes loading-progress {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}
.animate-loading-progress {
animation: loading-progress 1.5s ease-in-out infinite;
}
</style>
<script define:vars={{ umamiConfig}}>
// 获取全站访问量统计
async function loadSiteStats() {
if (!umamiConfig.enable) return;
try {
const statsData = await fetchUmamiStats(umamiConfig.baseUrl, umamiConfig.shareId, {
timezone: umamiConfig.timezone
});
const pageviews = statsData.pageviews || 0;
const visitors = statsData.visitors || 0;
const viewsElement = document.getElementById('site-views');
const visitorsElement = document.getElementById('site-visitors');
if (viewsElement) viewsElement.textContent = pageviews;
if (visitorsElement) visitorsElement.textContent = visitors;
} catch (error) {
console.error('获取全站统计失败:', error);
}
}
// 页面加载完成后获取统计数据
document.addEventListener('DOMContentLoaded', loadSiteStats);
</script>

View File

@@ -0,0 +1,24 @@
---
import type { MarkdownHeading } from "astro";
import Profile from "./Profile.astro";
import CategoryList from "./CategoryList.astro";
interface Props {
class?: string;
headings?: MarkdownHeading[];
}
const className = Astro.props.class;
---
<div id="sidebar" class:list={[className, "w-full"]}>
<div class="flex flex-col w-full gap-4 mb-4">
<Profile></Profile>
<CategoryList />
</div>
<div id="sidebar-sticky" class="transition-all duration-700 flex flex-col w-full gap-4 top-4 sticky top-4">
</div>
</div>

View File

@@ -0,0 +1,268 @@
---
import type { MarkdownHeading } from "astro";
import { siteConfig } from "../../config";
interface Props {
class?: string;
headings: MarkdownHeading[];
}
let { headings = [] } = Astro.props;
let minDepth = 10;
for (const heading of headings) {
minDepth = Math.min(minDepth, heading.depth);
}
const className = Astro.props.class;
const isPostsRoute = Astro.url.pathname.startsWith("/posts/");
const removeTailingHash = (text: string) => {
let lastIndexOfHash = text.lastIndexOf("#");
if (lastIndexOfHash !== text.length - 1) {
return text;
}
return text.substring(0, lastIndexOfHash);
};
let heading1Count = 1;
const maxLevel = siteConfig.toc.depth;
---
{isPostsRoute &&
<table-of-contents class:list={[className, "group"]}>
{headings.filter((heading) => heading.depth < minDepth + maxLevel).map((heading) =>
<a href={`#${heading.slug}`} class="px-2 flex gap-2 relative transition w-full min-h-9 rounded-xl
hover:bg-[var(--toc-btn-hover)] active:bg-[var(--toc-btn-active)] py-2
">
<div class:list={["transition w-5 h-5 shrink-0 rounded-lg text-xs flex items-center justify-center font-bold",
{
"bg-[var(--toc-badge-bg)] text-[var(--btn-content)]": heading.depth == minDepth,
"ml-4": heading.depth == minDepth + 1,
"ml-8": heading.depth == minDepth + 2,
}
]}
>
{heading.depth == minDepth && heading1Count++}
{heading.depth == minDepth + 1 && <div class="transition w-2 h-2 rounded-[0.1875rem] bg-[var(--toc-badge-bg)]"></div>}
{heading.depth == minDepth + 2 && <div class="transition w-1.5 h-1.5 rounded-sm bg-black/5 dark:bg-white/10"></div>}
</div>
<div class:list={["transition text-sm", {
"text-50": heading.depth == minDepth || heading.depth == minDepth + 1,
"text-30": heading.depth == minDepth + 2,
}]}>{removeTailingHash(heading.text)}</div>
</a>
)}
<div id="active-indicator" class:list={[{'hidden': headings.length == 0}, "-z-10 absolute bg-[var(--toc-btn-hover)] left-0 right-0 rounded-xl transition-all " +
"group-hover:bg-transparent border-2 border-[var(--toc-btn-hover)] group-hover:border-[var(--toc-btn-active)] border-dashed"]}></div>
</table-of-contents>}
<script>
class TableOfContents extends HTMLElement {
tocEl: HTMLElement | null = null;
visibleClass = "visible";
observer: IntersectionObserver;
anchorNavTarget: HTMLElement | null = null;
headingIdxMap = new Map<string, number>();
headings: HTMLElement[] = [];
sections: HTMLElement[] = [];
tocEntries: HTMLAnchorElement[] = [];
active: boolean[] = [];
activeIndicator: HTMLElement | null = null;
constructor() {
super();
this.observer = new IntersectionObserver(
this.markVisibleSection, { threshold: 0 }
);
};
markVisibleSection = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
const id = entry.target.children[0]?.getAttribute("id");
const idx = id ? this.headingIdxMap.get(id) : undefined;
if (idx != undefined)
this.active[idx] = entry.isIntersecting;
if (entry.isIntersecting && this.anchorNavTarget == entry.target.firstChild)
this.anchorNavTarget = null;
});
if (!this.active.includes(true)) {
// 当没有任何标题在视窗中时,隐藏 active-indicator
this.activeIndicator?.setAttribute("style", "display: none;");
this.fallback();
return;
}
this.update();
};
toggleActiveHeading = () => {
let i = this.active.length - 1;
let min = this.active.length - 1, max = 0;
while (i >= 0 && !this.active[i]) {
this.tocEntries[i].classList.remove(this.visibleClass);
i--;
}
while (i >= 0 && this.active[i]) {
this.tocEntries[i].classList.add(this.visibleClass);
min = Math.min(min, i);
max = Math.max(max, i);
i--;
}
while (i >= 0) {
this.tocEntries[i].classList.remove(this.visibleClass);
i--;
}
let parentOffset = this.tocEl?.getBoundingClientRect().top || 0;
let scrollOffset = this.tocEl?.scrollTop || 0;
let top = this.tocEntries[min].getBoundingClientRect().top - parentOffset + scrollOffset;
let bottom = this.tocEntries[max].getBoundingClientRect().bottom - parentOffset + scrollOffset;
this.activeIndicator?.setAttribute("style", `display: block; top: ${top}px; height: ${bottom - top}px`);
};
scrollToActiveHeading = () => {
// If the TOC widget can accommodate both the topmost
// and bottommost items, scroll to the topmost item.
// Otherwise, scroll to the bottommost one.
if (this.anchorNavTarget || !this.tocEl) return;
const activeHeading =
document.querySelectorAll<HTMLDivElement>(`#toc .${this.visibleClass}`);
if (!activeHeading.length) return;
const topmost = activeHeading[0];
const bottommost = activeHeading[activeHeading.length - 1];
const tocHeight = this.tocEl.clientHeight;
let top;
if (bottommost.getBoundingClientRect().bottom -
topmost.getBoundingClientRect().top < 0.9 * tocHeight)
top = topmost.offsetTop - 32;
else
top = bottommost.offsetTop - tocHeight * 0.8;
this.tocEl.scrollTo({
top,
left: 0,
behavior: "smooth",
});
};
update = () => {
requestAnimationFrame(() => {
this.toggleActiveHeading();
// requestAnimationFrame(() => {
this.scrollToActiveHeading();
// });
});
};
fallback = () => {
if (!this.sections.length) return;
for (let i = 0; i < this.sections.length; i++) {
let offsetTop = this.sections[i].getBoundingClientRect().top;
let offsetBottom = this.sections[i].getBoundingClientRect().bottom;
if (this.isInRange(offsetTop, 0, window.innerHeight)
|| this.isInRange(offsetBottom, 0, window.innerHeight)
|| (offsetTop < 0 && offsetBottom > window.innerHeight)) {
this.markActiveHeading(i);
}
else if (offsetTop > window.innerHeight) break;
}
};
markActiveHeading = (idx: number)=> {
this.active[idx] = true;
};
handleAnchorClick = (event: Event) => {
const anchor = event
.composedPath()
.find((element) => element instanceof HTMLAnchorElement);
if (anchor) {
const id = decodeURIComponent(anchor.hash?.substring(1));
const idx = this.headingIdxMap.get(id);
if (idx !== undefined) {
this.anchorNavTarget = this.headings[idx];
} else {
this.anchorNavTarget = null;
}
}
};
isInRange(value: number, min: number, max: number) {
return min < value && value < max;
};
connectedCallback() {
// wait for the onload animation to finish, which makes the `getBoundingClientRect` return correct values
const element = document.querySelector('.prose');
if (element) {
element.addEventListener('animationend', () => {
this.init();
}, { once: true });
} else {
console.debug('Animation element not found');
}
};
init() {
this.tocEl = document.getElementById(
"toc-inner-wrapper"
);
if (!this.tocEl) return;
this.tocEl.addEventListener("click", this.handleAnchorClick, {
capture: true,
});
this.activeIndicator = document.getElementById("active-indicator");
this.tocEntries = Array.from(
document.querySelectorAll<HTMLAnchorElement>("#toc a[href^='#']")
);
if (this.tocEntries.length === 0) return;
this.sections = new Array(this.tocEntries.length);
this.headings = new Array(this.tocEntries.length);
for (let i = 0; i < this.tocEntries.length; i++) {
const id = decodeURIComponent(this.tocEntries[i].hash?.substring(1));
const heading = document.getElementById(id);
const section = heading?.parentElement;
if (heading instanceof HTMLElement && section instanceof HTMLElement) {
this.headings[i] = heading;
this.sections[i] = section;
this.headingIdxMap.set(id, i);
}
}
this.active = new Array(this.tocEntries.length).fill(false);
this.sections.forEach((section) =>
this.observer.observe(section)
);
this.fallback();
this.update();
};
disconnectedCallback() {
this.sections.forEach((section) =>
this.observer.unobserve(section)
);
this.observer.disconnect();
this.tocEl?.removeEventListener("click", this.handleAnchorClick);
};
}
if (!customElements.get("table-of-contents")) {
customElements.define("table-of-contents", TableOfContents);
}
</script>