Files
blog-fuwari/src/pages/posts/[...slug].astro
T
2026-05-23 21:18:44 +08:00

687 lines
35 KiB
Plaintext

---
import { type CollectionEntry, render } from "astro:content";
import path from "node:path";
import License from "@components/misc/License.astro";
import Markdown from "@components/misc/Markdown.astro";
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import MainGridLayout from "@layouts/MainGridLayout.astro";
import { getSortedPosts } from "@utils/content-utils";
import { getDir, getPostUrlBySlug } from "@utils/url-utils";
import { Icon } from "astro-icon/components";
import { licenseConfig } from "src/config";
import ImageWrapper from "../../components/misc/ImageWrapper.astro";
import PostMetadata from "../../components/PostMeta.astro";
import { profileConfig, siteConfig } from "../../config";
import { formatDateToYYYYMMDD } from "../../utils/date-utils";
export async function getStaticPaths() {
const blogEntries = await getSortedPosts();
return blogEntries.map((entry) => ({
params: { slug: entry.id },
props: { entry },
}));
}
interface Props {
entry: CollectionEntry<"posts">;
}
const { entry }: Props = Astro.props;
const { Content, headings, remarkPluginFrontmatter } = await render(entry);
const postSlug = entry.id;
const lastUpdatedAt = entry.data.updated ?? entry.data.published;
const lastUpdatedLabel = formatDateToYYYYMMDD(lastUpdatedAt);
const lastUpdatedTimestamp = lastUpdatedAt.getTime();
const postSources = import.meta.glob("../../content/posts/**/*.{md,mdx}", {
query: "?raw",
import: "default",
eager: true,
}) as Record<string, string>;
const normalizedPostSources = Object.fromEntries(
Object.entries(postSources).map(([key, source]) => [
key.toLowerCase(),
source,
]),
);
function getPostSourceKey(entryId: string) {
const normalizedEntryPath = `../../content/posts/${entryId}`.toLowerCase();
return Object.keys(postSources).find((key) => {
const normalizedKey = key.toLowerCase();
return (
normalizedKey === normalizedEntryPath ||
normalizedKey === `${normalizedEntryPath}.md` ||
normalizedKey === `${normalizedEntryPath}.mdx`
);
});
}
function getPostSourcePath(sourceKey: string | undefined) {
return sourceKey?.replace("../../content/posts/", "src/content/posts/");
}
function getGitHubPostSourceUrl(
repo: string | undefined,
sourcePath: string | undefined,
) {
if (!repo || !sourcePath) return "";
const repoUrl = repo.includes("://") ? repo : `https://github.com/${repo}`;
const normalizedRepo = repoUrl.replace(/\.git$/, "").replace(/\/+$/, "");
const encodedSourcePath = sourcePath
.split("/")
.map(encodeURIComponent)
.join("/");
return `${normalizedRepo}/blob/main/${encodedSourcePath}?plain=1`;
}
function getGitHubRawSourceUrl(
repo: string | undefined,
sourcePath: string | undefined,
) {
if (!repo || !sourcePath) return "";
const normalizedRepo = repo.replace(/\.git$/, "").replace(/\/+$/, "");
const repoPath = normalizedRepo.includes("://")
? new URL(normalizedRepo).pathname.replace(/^\/+|\/+$/g, "")
: normalizedRepo.replace(/^\/+|\/+$/g, "");
const [owner, repository] = repoPath.split("/");
if (!owner || !repository) return "";
const encodedSourcePath = sourcePath
.split("/")
.map(encodeURIComponent)
.join("/");
return `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/refs/heads/main/${encodedSourcePath}`;
}
function getGiteaRawSourceUrl(
repo: string | undefined,
sourcePath: string | undefined,
giteaHost: string,
) {
if (!repo || !sourcePath) return "";
const normalizedRepo = repo.replace(/\.git$/, "").replace(/\/+$/, "");
const repoPath = normalizedRepo.includes("://")
? new URL(normalizedRepo).pathname.replace(/^\/+|\/+$/g, "")
: normalizedRepo.replace(/^\/+|\/+$/g, "");
const [owner, repository] = repoPath.split("/");
if (!owner || !repository) return "";
const encodedSourcePath = sourcePath
.split("/")
.map(encodeURIComponent)
.join("/");
return `https://${giteaHost}/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/src/branch/main/${encodedSourcePath}?display=source`;
}
function getCnbsSourceUrl(baseUrl: string, sourcePath: string | undefined) {
if (!sourcePath) return "";
const normalizedUrl = baseUrl.replace(/\.git$/, "").replace(/\/+$/, "");
const encodedSourcePath = sourcePath
.split("/")
.map(encodeURIComponent)
.join("/");
return `${normalizedUrl}/-/blob/main/${encodedSourcePath}`;
}
function parseAiSummaryValue(value: string) {
const trimmed = value.trim();
if (!trimmed) return "";
if (trimmed.startsWith('"')) {
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
return trimmed;
}
function extractFrontmatterValue(frontmatter: string, key: string) {
const valueMatch = frontmatter.match(new RegExp(`^${key}:[ \\t]*(.*)$`, "m"));
if (!valueMatch) return "";
const inlineValue = valueMatch[1].trim();
if (inlineValue !== ">" && inlineValue !== "|") {
return parseAiSummaryValue(inlineValue);
}
const afterValue = frontmatter.slice(
valueMatch.index! + valueMatch[0].length,
);
const lines = afterValue.split(/\r?\n/);
const blockLines = [];
for (const line of lines) {
if (!line.startsWith(" ") && line.trim()) break;
blockLines.push(line.replace(/^ {1,2}/, ""));
}
return blockLines.join("\n").trim();
}
function getAiSummaryMeta(entryId: string) {
const source =
normalizedPostSources[`../../content/posts/${entryId}`.toLowerCase()] ??
normalizedPostSources[`../../content/posts/${entryId}.md`.toLowerCase()] ??
normalizedPostSources[`../../content/posts/${entryId}.mdx`.toLowerCase()];
const frontmatter = source?.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
return {
summary: frontmatter
? extractFrontmatterValue(frontmatter, "aiSummary")
: "",
model: frontmatter
? extractFrontmatterValue(frontmatter, "aiSummaryModel")
: "",
};
}
const aiSummaryMeta = getAiSummaryMeta(entry.id);
const postSourceKey = getPostSourceKey(entry.id);
const postSourcePath = getPostSourcePath(postSourceKey);
const postSourceUrl = getGitHubPostSourceUrl(
siteConfig.githubRepo,
postSourcePath,
);
const postRawSourceUrl = getGitHubRawSourceUrl(
siteConfig.githubRepo,
postSourcePath,
);
const postGiteaRawSourceUrl = getGiteaRawSourceUrl(
siteConfig.githubRepo,
postSourcePath,
"git.adclosenn.top",
);
const postCnbsSourceUrl = getCnbsSourceUrl(
"https://cnb.cool/CLN-Grated/blog-fuwari",
postSourcePath,
);
const aiPrompt = `Read from ${postRawSourceUrl} so I can ask questions about it.`;
const encodedAiPrompt = encodeURIComponent(aiPrompt);
const copyPageAiLinks = [
{
label: "Gitea",
icon: "cln-gitea",
href: postGiteaRawSourceUrl,
},
{
label: "CNB",
icon: "cnb",
href: postCnbsSourceUrl,
},
{
label: "ChatGPT",
icon: "gpt",
href: `https://chatgpt.com/?q=${encodedAiPrompt}`,
},
{
label: "Gemini",
icon: "gemini",
href: `https://gemini.google.com/app?q=${encodedAiPrompt}`,
},
{
label: "Claude",
icon: "claude",
href: `https://claude.ai/new?q=${encodedAiPrompt}`,
},
{
label: "Grok",
icon: "grok",
href: `https://grok.com/?q=${encodedAiPrompt}`,
},
];
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: entry.data.title,
description: entry.data.description || entry.data.title,
keywords: entry.data.tags,
author: {
"@type": "Person",
name: profileConfig.name,
url: Astro.site,
},
datePublished: formatDateToYYYYMMDD(entry.data.published),
inLanguage: entry.data.lang
? entry.data.lang.replace("_", "-")
: siteConfig.lang.replace("_", "-"),
// TODO include cover image here
};
// 获取头图 boolean,无需设置 true
const showcover = entry.data.showcover;
// 获取自定义的头图(Markdown 内部)
const customcover = entry.data.customcover;
const isOutdated = entry.data.outdated;
---
<MainGridLayout banner={entry.data.image} title={entry.data.title} description={entry.data.description} lang={entry.data.lang} setOGTypeArticle={true} headings={headings}>
<script is:inline slot="head" type="application/ld+json" set:html={JSON.stringify(jsonLd)}></script>
<div class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative mb-4">
<div id="post-container" class:list={["card-base z-10 px-6 md:px-9 pt-6 pb-4 relative w-full ",
{}
]}>
<!-- word count and reading time -->
<div class="flex flex-col gap-3 mb-3 transition onload-animation md:flex-row md:items-start md:justify-between">
<div class="flex flex-row flex-wrap text-black/30 dark:text-white/30 gap-5 transition">
<div class="flex flex-row items-center pointer-events-none select-none cursor-default">
<div class="transition h-6 w-6 rounded-md bg-black/5 dark:bg-white/10 text-black/50 dark:text-white/50 flex items-center justify-center mr-2">
<Icon is:inline name="material-symbols:notes-rounded"></Icon>
</div>
<div class="text-sm">{remarkPluginFrontmatter.words} {" " + i18n(I18nKey.wordsCount)}</div>
</div>
<div class="flex flex-row items-center pointer-events-none select-none cursor-default">
<div class="transition h-6 w-6 rounded-md bg-black/5 dark:bg-white/10 text-black/50 dark:text-white/50 flex items-center justify-center mr-2">
<Icon is:inline name="material-symbols:schedule-outline-rounded"></Icon>
</div>
<div class="text-sm">
大约 {remarkPluginFrontmatter.minutes} {" " + i18n(remarkPluginFrontmatter.minutes === 1 ? I18nKey.minuteCount : I18nKey.minutesCount)}
</div>
</div>
<div class="flex flex-row items-center pointer-events-none select-none cursor-default">
<div class="transition h-6 w-6 rounded-md bg-black/5 dark:bg-white/10 text-black/50 dark:text-white/50 flex items-center justify-center mr-2">
<Icon is:inline name="material-symbols:visibility-outline-rounded"></Icon>
</div>
<div class="text-sm" id="post-top-page-views">加载中...</div>
</div>
</div>
<div class="flex items-center justify-end gap-2 text-black/40 dark:text-white/40 md:shrink-0">
{postSourceUrl && (
<div id="copy-page-menu" class="relative inline-flex shrink-0 select-none rounded-xl border border-black/15 dark:border-white/15">
<a
href={postSourceUrl}
aria-label="在 GitHub 打开文章源文件"
title="在 GitHub 打开文章源文件"
target="_blank"
rel="noopener noreferrer"
class="btn-card flex h-9 items-center gap-2 rounded-l-xl rounded-r-none px-3 font-medium"
>
<Icon is:inline name="fa6-brands:github" class="text-[1rem] text-[var(--primary)]"></Icon>
<span class="text-sm text-black/75 dark:text-white/75">在 GitHub 查看</span>
</a>
<div class="h-9 w-px bg-black/10 dark:bg-white/10"></div>
<button id="copy-page-switch" class="btn-card flex h-9 w-9 items-center justify-center rounded-l-none rounded-r-xl active:scale-95" type="button" aria-label="打开 AI 菜单" aria-haspopup="menu" aria-expanded="false">
<Icon is:inline name="material-symbols:keyboard-arrow-down-rounded" class="copy-page-arrow text-[1.2rem] text-[var(--primary)] transition"></Icon>
</button>
<div id="copy-page-panel" class="pointer-events-none fixed z-[9999] w-56 -translate-y-1 select-none rounded-2xl border border-black/10 bg-[var(--float-panel-bg)] p-2 opacity-0 shadow-xl dark:border-white/10 dark:shadow-none" role="menu" style="transition: opacity 150ms ease-out, transform 150ms ease-out">
{copyPageAiLinks.map((link) => (
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
class="flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2 text-sm text-black/75 transition hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)] dark:text-white/75"
role="menuitem"
>
<span class="flex items-center gap-2">
{link.icon === "gpt" && (
<svg aria-hidden="true" class="h-4 w-4 text-[var(--primary)]" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5">
<path d="M11.745 14.85L6.905 12V7c0-2.21 1.824-4 4.076-4c1.397 0 2.63.69 3.365 1.741" />
<path d="M9.6 19.18A4.1 4.1 0 0 0 13.02 21c2.25 0 4.076-1.79 4.076-4v-5L12.16 9.097" />
<path d="M9.452 13.5V7.67l4.412-2.5c1.95-1.105 4.443-.45 5.569 1.463a3.93 3.93 0 0 1 .076 3.866" />
<path d="M4.49 13.5a3.93 3.93 0 0 0 .075 3.866c1.126 1.913 3.62 2.568 5.57 1.464l4.412-2.5l.096-5.596" />
<path d="M17.096 17.63a4.09 4.09 0 0 0 3.357-1.996c1.126-1.913.458-4.36-1.492-5.464l-4.413-2.5l-5.059 2.755" />
<path d="M6.905 6.37a4.09 4.09 0 0 0-3.358 1.996c-1.126 1.914-.458 4.36 1.492 5.464l4.413 2.5l5.048-2.75" />
</g>
</svg>
)}
{link.icon === "gemini" && (
<svg aria-hidden="true" class="h-4 w-4 text-[var(--primary)]" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M21.996 12.018a10.65 10.65 0 0 0-9.98 9.98h-.04c-.32-5.364-4.613-9.656-9.976-9.98v-.04c5.363-.32 9.656-4.613 9.98-9.976h.04c.324 5.363 4.617 9.656 9.98 9.98v.036z" />
</svg>
)}
{link.icon === "claude" && (
<svg aria-hidden="true" class="h-4 w-4 text-[var(--primary)]" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="m3.127 10.604l3.135-1.76l.053-.153l-.053-.085H6.11l-.525-.032l-1.791-.048l-1.554-.065l-1.505-.08l-.38-.081L0 7.832l.036-.234l.32-.214l.455.04l1.009.069l1.513.105l1.097.064l1.626.17h.259l.036-.105l-.089-.065l-.068-.064l-1.566-1.062l-1.695-1.121l-.887-.646l-.48-.327l-.243-.306l-.104-.67l.435-.48l.585.04l.15.04l.593.456l1.267.981l1.654 1.218l.242.202l.097-.068l.012-.049l-.109-.181l-.9-1.626l-.96-1.655l-.428-.686l-.113-.411a2 2 0 0 1-.068-.484l.496-.674L4.446 0l.662.089l.279.242l.411.94l.666 1.48l1.033 2.014l.302.597l.162.553l.06.17h.105v-.097l.085-1.134l.157-1.392l.154-1.792l.052-.504l.25-.605l.497-.327l.387.186l.319.456l-.045.294l-.19 1.23l-.37 1.93l-.243 1.29h.142l.161-.16l.654-.868l1.097-1.372l.484-.545l.565-.601l.363-.287h.686l.505.751l-.226.775l-.707.895l-.585.759l-.839 1.13l-.524.904l.048.072l.125-.012l1.897-.403l1.024-.186l1.223-.21l.553.258l.06.263l-.218.536l-1.307.323l-1.533.307l-2.284.54l-.028.02l.032.04l1.029.098l.44.024h1.077l2.005.15l.525.346l.315.424l-.053.323l-.807.411l-3.631-.863l-.872-.218h-.12v.073l.726.71l1.331 1.202l1.667 1.55l.084.383l-.214.302l-.226-.032l-1.464-1.101l-.565-.497l-1.28-1.077h-.084v.113l.295.432l1.557 2.34l.08.718l-.112.234l-.404.141l-.444-.08l-.911-1.28l-.94-1.44l-.759-1.291l-.093.053l-.448 4.821l-.21.246l-.484.186l-.403-.307l-.214-.496l.214-.98l.258-1.28l.21-1.016l.19-1.263l.112-.42l-.008-.028l-.092.012l-.953 1.307l-1.448 1.957l-1.146 1.227l-.274.109l-.477-.247l.045-.44l.266-.39l1.586-2.018l.956-1.25l.617-.723l-.004-.105h-.036l-4.212 2.736l-.75.096l-.324-.302l.04-.496l.154-.162l1.267-.871z" />
</svg>
)}
{link.icon === "grok" && (
<svg aria-hidden="true" class="h-4 w-4 text-[var(--primary)]" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M4.94 4.96a9.97 9.97 0 0 1 10.835-2.182a8.7 8.7 0 0 1 2.033 1.11l-3.006 1.39C12.003 4.101 8.797 4.9 6.84 6.86c-2.564 2.565-3.146 6.954-.36 9.922l.278.284L.124 23c1.875-1.973 3.771-4.427 2.636-7.19c-1.52-3.698-.635-8.03 2.18-10.85M23.9.1c-2.264 3.174-3.184 5.389-2.197 9.64l-.007-.007c.753 3.201-.052 6.75-2.653 9.355c-3.279 3.285-8.526 4.016-12.847 1.06L9.21 18.75c2.758 1.084 5.775.607 7.943-1.564c2.169-2.17 2.655-5.332 1.566-7.963c-.207-.5-.828-.625-1.263-.304L8.59 15.472l12.7-12.77v.01z" />
</svg>
)}
{link.icon === "cln-gitea" && (
<img aria-hidden="true" class="h-4 w-4" src="https://git.adclosenn.top/assets/img/logo.svg" alt="" />
)}
{link.icon === "cnb" && (
<img aria-hidden="true" class="h-4 w-4" src="https://cnb.cool/images/favicon.svg" alt="" />
)}
<span>在 {link.label} 中打开</span>
</span>
<Icon is:inline name="material-symbols:open-in-new-rounded" class="text-[0.95rem] text-[var(--primary)]"></Icon>
</a>
))}
</div>
</div>
)}
<a
href={entry.data.nextSlug ? getPostUrlBySlug(entry.data.nextSlug) : "#"}
aria-label={entry.data.nextTitle ? `上一篇:${entry.data.nextTitle}` : "没有上一篇"}
title={entry.data.nextTitle || undefined}
class:list={[
"btn-card h-9 w-9 rounded-xl active:scale-95",
{"pointer-events-none opacity-40": !entry.data.nextSlug},
]}
>
<Icon is:inline name="material-symbols:chevron-left-rounded" class="text-[1.5rem] text-[var(--primary)]"></Icon>
</a>
<a
href={entry.data.prevSlug ? getPostUrlBySlug(entry.data.prevSlug) : "#"}
aria-label={entry.data.prevTitle ? `下一篇:${entry.data.prevTitle}` : "没有下一篇"}
title={entry.data.prevTitle || undefined}
class:list={[
"btn-card h-9 w-9 rounded-xl active:scale-95",
{"pointer-events-none opacity-40": !entry.data.prevSlug},
]}
>
<Icon is:inline name="material-symbols:chevron-right-rounded" class="text-[1.5rem] text-[var(--primary)]"></Icon>
</a>
</div>
</div>
<!-- title -->
<div class="relative onload-animation">
<div
data-pagefind-body data-pagefind-weight="10" data-pagefind-meta="title"
class="transition w-full block font-bold mb-3
text-3xl md:text-[2.25rem]/[2.75rem]
text-[var(--primary)]
underline decoration-[3px] decoration-[var(--primary)] underline-offset-[0.45rem]
md:before:w-1 before:h-5 before:rounded-md before:bg-[var(--primary)]
before:absolute before:top-[0.75rem] before:left-[-1.125rem]
">
{entry.data.title}
</div>
</div>
<!-- description/excerpt -->
<!-- https://github.com/afoim/fuwari/commit/e34064899f0a3e7f28107f06213301f7c1167084 -->
{(entry.data.description || remarkPluginFrontmatter.excerpt) && (
<div class="onload-animation mb-4">
<div class="text-black/75 dark:text-white/75 text-sm leading-relaxed">
{entry.data.description || remarkPluginFrontmatter.excerpt}
</div>
</div>
)}
<div class:list={[
"onload-animation mb-4 rounded-xl px-4 py-3 text-sm",
isOutdated
? "border border-red-500/20 bg-red-500/8 text-red-700 dark:text-red-300"
: "border border-amber-500/20 bg-amber-500/8 text-amber-700 dark:text-amber-300",
]}>
<div class="flex items-start gap-2">
<Icon
is:inline
name={isOutdated ? "material-symbols:warning-outline-rounded" : "material-symbols:info-outline-rounded"}
class="mt-0.5 shrink-0 text-lg"
></Icon>
<div class="leading-relaxed">
{isOutdated ? (
<>本文部分内容可能已失效,请谨慎参考。</>
) : (
<>
本文最后更新于 <span class="font-medium">{lastUpdatedLabel}</span>,距今 <span class="font-medium" id="post-last-updated-distance">计算中...</span>。
随着时间推移,文中描述可能与当前实际情况有出入,请注意甄别。
</>
)}
</div>
</div>
</div>
<!-- metadata -->
<div class="onload-animation">
<PostMetadata
class="mb-5"
published={entry.data.published}
updated={entry.data.updated}
tags={entry.data.tags}
category={entry.data.category}
></PostMetadata>
{!entry.data.image && <div class="border-[var(--line-divider)] border-dashed border-b-[1px] mb-5"></div>}
</div>
{aiSummaryMeta.summary && (
<div class="mb-4 rounded-xl p-4 bg-gradient-to-r from-violet-500/10 to-indigo-500/10 dark:from-violet-500/15 dark:to-indigo-500/15 border border-violet-500/20 onload-animation">
<div class="flex items-start gap-3">
<div class="shrink-0 w-8 h-8 rounded-lg bg-violet-500/20 dark:bg-violet-500/30 flex items-center justify-center">
<Icon is:inline name="material-symbols:info-outline-rounded" class="text-violet-600 dark:text-violet-400 text-lg"></Icon>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-violet-700 dark:text-violet-300 mb-1 select-none pointer-events-none">
AI 摘要{aiSummaryMeta.model && <span class="text-violet-600/70 dark:text-violet-300/70"> · {aiSummaryMeta.model}</span>}
</div>
<div class="text-sm text-black/70 dark:text-white/70 leading-relaxed">{aiSummaryMeta.summary}</div>
</div>
</div>
</div>
)}
<!-- always show cover as long as it has one -->
<!-- 使用自制 showcover 控制器控制头图的出现 -->
{showcover && entry.data.image &&
<ImageWrapper id="post-cover" src={entry.data.image} basePath={path.join("content/posts/", getDir(entry.id))} class="mb-8 rounded-xl banner-container onload-animation" loader/>
}
<!-- 自制内部头图 customcover-->
{customcover &&
<ImageWrapper id="post-cover" src={entry.data.customcover} basePath={path.join("content/posts/", getDir(entry.id))} class="mb-8 rounded-xl banner-container onload-animation" loader/>
}
<!-- 头图调试代码
<pre>
showcover(变量): {showcover} (type: {typeof showcover})
entry.data.showcover: {entry.data.showcover} (type: {typeof entry.data.showcover})
</pre>
-->
<Markdown class="mb-6 markdown-content onload-animation">
<Content />
</Markdown>
{licenseConfig.enable && <License title={entry.data.title} slug={entry.id} pubDate={entry.data.published} class="mb-6 rounded-xl license-container onload-animation"></License>}
</div>
</div>
<div class="flex flex-col md:flex-row justify-between mb-4 gap-4 overflow-hidden w-full">
<a href={entry.data.nextSlug ? getPostUrlBySlug(entry.data.nextSlug) : "#"}
class:list={["w-full font-bold overflow-hidden active:scale-95", {"pointer-events-none": !entry.data.nextSlug}]}>
{entry.data.nextSlug && <div class="btn-card rounded-2xl w-full h-[3.75rem] max-w-full px-4 flex items-center !justify-start gap-4" >
<Icon is:inline name="material-symbols:chevron-left-rounded" class="text-[2rem] text-[var(--primary)]" />
<div class="overflow-hidden transition overflow-ellipsis whitespace-nowrap max-w-[calc(100%_-_3rem)] text-black/75 dark:text-white/75">
{entry.data.nextTitle}
</div>
</div>}
</a>
<a href={entry.data.prevSlug ? getPostUrlBySlug(entry.data.prevSlug) : "#"}
class:list={["w-full font-bold overflow-hidden active:scale-95", {"pointer-events-none": !entry.data.prevSlug}]}>
{entry.data.prevSlug && <div class="btn-card rounded-2xl w-full h-[3.75rem] max-w-full px-4 flex items-center !justify-end gap-4">
<div class="overflow-hidden transition overflow-ellipsis whitespace-nowrap max-w-[calc(100%_-_3rem)] text-black/75 dark:text-white/75">
{entry.data.prevTitle}
</div>
<Icon is:inline name="material-symbols:chevron-right-rounded" class="text-[2rem] text-[var(--primary)]" />
</div>}
</a>
</div>
<script define:vars={{ slug: postSlug, lastUpdatedTimestamp }}>
function formatUpdatedDistance(timestamp) {
const now = Date.now();
const diff = Math.max(0, now - timestamp);
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
const month = 30 * day;
const year = 365 * day;
if (diff < hour) {
const minutes = Math.max(1, Math.floor(diff / minute));
return `${minutes} 分钟`;
}
if (diff < day) {
return `${Math.floor(diff / hour)} 小时`;
}
if (diff < month) {
return `${Math.floor(diff / day)} 天`;
}
if (diff < year) {
return `${Math.floor(diff / month)} 个月`;
}
return `${Math.floor(diff / year)} 年`;
}
function renderUpdatedDistance() {
const distanceElement = document.getElementById('post-last-updated-distance');
if (!distanceElement) {
return;
}
distanceElement.textContent = formatUpdatedDistance(lastUpdatedTimestamp);
}
async function fetchTopPageViews() {
const displayElement = document.getElementById('post-top-page-views');
if (!displayElement || displayElement.dataset.umamiState === 'loading' || displayElement.dataset.umamiState === 'loaded') {
return;
}
displayElement.dataset.umamiState = 'loading';
try {
const umamiStore = window['__blogUmami'];
const statsData = await umamiStore?.getStats(`post:${slug}`, (websiteId) => {
const currentTimestamp = Date.now();
return `https://umami.adclosenn.top/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent('Asia/Shanghai')}&url=%2Fposts%2F${slug}%2F&compare=false`;
});
if (!statsData) {
throw new Error('统计功能未启用');
}
const pageViews = statsData.pageviews?.value || 0;
displayElement.textContent = `浏览量 ${pageViews}`;
displayElement.dataset.umamiState = 'loaded';
} catch (error) {
console.error('Error fetching top page views:', error);
displayElement.textContent = '统计不可用';
displayElement.dataset.umamiState = 'error';
}
}
function setupCopyPageMenu() {
const menu = document.getElementById('copy-page-menu');
const switchButton = document.getElementById('copy-page-switch');
let panel = document.getElementById('copy-page-panel');
const arrow = menu?.querySelector('.copy-page-arrow');
if (!menu || !switchButton || !panel) return;
// 将 panel 移到 body,彻底避免祖先 transform/overflow 等对 fixed 定位的干扰
if (panel.parentElement !== document.body) {
// 清理上一页可能残留在 body 中的旧 panel
const stale = document.body.querySelector('#copy-page-panel');
if (stale && stale !== panel) stale.remove();
document.body.appendChild(panel);
}
// 强制重置为关闭状态(只改 opacity/transform,不清位置,避免闪现)
function resetPanel() {
panel.classList.add('pointer-events-none', 'opacity-0', '-translate-y-1');
panel.classList.remove('opacity-100', 'translate-y-0');
if (arrow) arrow.classList.remove('rotate-180');
switchButton.setAttribute('aria-expanded', 'false');
}
resetPanel();
if (switchButton.dataset.copyPageReady) return;
function setMenuOpen(opening) {
if (opening) {
const menuRect = menu.getBoundingClientRect();
panel.style.top = `${menuRect.bottom + 4}px`;
panel.style.right = `${window.innerWidth - menuRect.right}px`;
}
panel.classList.toggle('pointer-events-none', !opening);
panel.classList.toggle('opacity-0', !opening);
panel.classList.toggle('-translate-y-1', !opening);
panel.classList.toggle('opacity-100', opening);
panel.classList.toggle('translate-y-0', opening);
arrow?.classList.toggle('rotate-180', opening);
switchButton.setAttribute('aria-expanded', String(opening));
}
switchButton.dataset.copyPageReady = 'true';
switchButton.addEventListener('click', () => {
setMenuOpen(panel.classList.contains('opacity-0'));
});
// 当菜单按钮滚出视口(被 navbar 遮挡或滚到下方)时自动关闭 panel
if (document.body['__copyPageObserver']) {
document.body['__copyPageObserver'].disconnect();
}
document.body['__copyPageObserver'] = new IntersectionObserver((entries) => {
if (!entries[0].isIntersecting) {
setMenuOpen(false);
}
}, { rootMargin: '-80px 0px 0px 0px', threshold: 0 });
document.body['__copyPageObserver'].observe(menu);
if (!document.body.dataset.copyPageOutsideReady) {
document.body.dataset.copyPageOutsideReady = 'true';
document.addEventListener('click', (event) => {
const currentMenu = document.getElementById('copy-page-menu');
const currentPanel = document.getElementById('copy-page-panel');
const currentSwitch = document.getElementById('copy-page-switch');
const currentArrow = currentMenu?.querySelector('.copy-page-arrow');
// 点击 menu 或 panel 内部时不关闭
if (!(event.target instanceof Node)) return;
if (currentMenu?.contains(event.target)) return;
if (currentPanel?.contains(event.target)) return;
// 关闭
currentPanel?.classList.add('pointer-events-none', 'opacity-0', '-translate-y-1');
currentPanel?.classList.remove('opacity-100', 'translate-y-0');
currentArrow?.classList.remove('rotate-180');
currentSwitch?.setAttribute('aria-expanded', 'false');
});
}
}
function initPostPage() {
renderUpdatedDistance();
fetchTopPageViews();
setupCopyPageMenu();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPostPage);
} else {
initPostPage();
}
document.addEventListener('astro:page-load', initPostPage);
</script>
<!-- 评论区 -->
<script src="https://giscus.app/client.js"
data-repo="Ad-closeNN/blog-friends"
data-repo-id="R_kgDOPb5ZJw"
data-category="General"
data-category-id="DIC_kwDOPb5ZJ84CuPmR"
data-mapping="title"
data-strict="0"
data-reactions-enabled="1"
data-emit-metadata="0"
data-input-position="top"
data-theme="preferred_color_scheme"
data-lang="zh-CN"
data-loading="lazy"
crossorigin="anonymous"
async>
</script>
</MainGridLayout>