This commit is contained in:
93
src/components/ArchivePanel.astro
Normal file
93
src/components/ArchivePanel.astro
Normal 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>
|
||||
37
src/components/CategoryPanel.astro
Normal file
37
src/components/CategoryPanel.astro
Normal 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>
|
||||
62
src/components/CategoryTree.astro
Normal file
62
src/components/CategoryTree.astro
Normal 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>
|
||||
7
src/components/ConfigCarrier.astro
Normal file
7
src/components/ConfigCarrier.astro
Normal 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
170
src/components/Footer.astro
Normal 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">
|
||||
© <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
127
src/components/Navbar.astro
Normal 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>
|
||||
112
src/components/PostCard.astro
Normal file
112
src/components/PostCard.astro
Normal 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>
|
||||
136
src/components/PostMeta.astro
Normal file
136
src/components/PostMeta.astro
Normal 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>
|
||||
)}
|
||||
29
src/components/PostPage.astro
Normal file
29
src/components/PostPage.astro
Normal 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>
|
||||
206
src/components/Search.svelte
Normal file
206
src/components/Search.svelte
Normal 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>
|
||||
49
src/components/control/BackToTop.astro
Normal file
49
src/components/control/BackToTop.astro
Normal 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>
|
||||
83
src/components/control/Pagination.astro
Normal file
83
src/components/control/Pagination.astro
Normal 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>
|
||||
90
src/components/misc/ImageWrapper.astro
Normal file
90
src/components/misc/ImageWrapper.astro
Normal 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>
|
||||
|
||||
60
src/components/misc/License.astro
Normal file
60
src/components/misc/License.astro
Normal 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>
|
||||
|
||||
|
||||
42
src/components/misc/Markdown.astro
Normal file
42
src/components/misc/Markdown.astro
Normal 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>
|
||||
61
src/components/widget/CategoryDrawer.astro
Normal file
61
src/components/widget/CategoryDrawer.astro
Normal 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>
|
||||
85
src/components/widget/CategoryList.astro
Normal file
85
src/components/widget/CategoryList.astro
Normal 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"
|
||||
>
|
||||
查看全部分类 →
|
||||
</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>
|
||||
331
src/components/widget/DisplaySettings.svelte
Normal file
331
src/components/widget/DisplaySettings.svelte
Normal 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>
|
||||
32
src/components/widget/NavMenuPanel.astro
Normal file
32
src/components/widget/NavMenuPanel.astro
Normal 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>
|
||||
93
src/components/widget/Profile.astro
Normal file
93
src/components/widget/Profile.astro
Normal 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>
|
||||
24
src/components/widget/SideBar.astro
Normal file
24
src/components/widget/SideBar.astro
Normal 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>
|
||||
|
||||
|
||||
|
||||
268
src/components/widget/TOC.astro
Normal file
268
src/components/widget/TOC.astro
Normal 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>
|
||||
Reference in New Issue
Block a user