Files
AboutMe/src/layouts/base.astro
2026-02-02 22:47:52 +03:00

522 lines
20 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
import "katex/dist/katex.css";
import { siteConfig, profileConfig, umamiConfig, musicPlayerConfig, pioConfig } from "@/config";
import type { Favicon } from "@/types/config";
import { defaultFavicons } from "@constants/icon";
import {
BANNER_HEIGHT,
BANNER_HEIGHT_EXTEND,
BANNER_HEIGHT_HOME,
LIGHT_MODE,
DARK_MODE,
SYSTEM_MODE,
PAGE_WIDTH,
NAVBAR_HEIGHT,
} from "@constants/constants";
import { pathsEqual, url } from "@utils/url";
import FontLoader from "@components/fontLoader.astro";
import LoadingOverlay from "@components/loadingOverlay.astro";
import ConfigCarrier from "@components/configCarrier.astro";
import MusicPlayer from "@components/musicPlayer.svelte";
import Pio from "@components/pio.svelte";
import "@styles/navbar.css";
import "@styles/banner.css";
import "@styles/fancybox.css";
interface Props {
title?: string;
banner?: string;
description?: string;
lang?: string;
setOGTypeArticle?: boolean;
postSlug?: string;
}
let { title, banner, description, lang, setOGTypeArticle, postSlug } = Astro.props;
// apply a class to the body element to decide the height of the banner, only used for initial page load
const isHomePage = pathsEqual(Astro.url.pathname, url("/"));
// defines global css variables
// why doing this in BaseLayout instead of GlobalStyles: https://github.com/withastro/astro/issues/6728#issuecomment-1502203757
const configHue = siteConfig.themeColor.hue;
// 获取导航栏透明模式配置
const navbarTransparentMode =
siteConfig.wallpaper.banner?.navbar?.transparentMode || "semi";
// 判断是否应该显示顶部高光效果只在full和semifull模式下显示
const shouldShowTopHighlight =
navbarTransparentMode === "full" || navbarTransparentMode === "semifull";
// 获取默认banner图片的辅助函数
const getDefaultBanner = (): string => {
const src = siteConfig.wallpaper.src;
if (typeof src === "string") {
return src;
}
if (Array.isArray(src)) {
return src[0] || "";
}
if (src && typeof src === "object") {
// 优先使用desktop如果没有则使用mobile
const desktopSrc = src.desktop;
const mobileSrc = src.mobile;
if (typeof desktopSrc === "string") {
return desktopSrc;
}
if (Array.isArray(desktopSrc) && desktopSrc.length > 0) {
return desktopSrc[0];
}
if (typeof mobileSrc === "string") {
return mobileSrc;
}
if (Array.isArray(mobileSrc) && mobileSrc.length > 0) {
return mobileSrc[0];
}
}
return "";
};
// TODO don't use post cover as banner for now
banner = getDefaultBanner();
const enableBanner = siteConfig.wallpaper.mode === "banner";
let pageTitle: string;
if (title) {
pageTitle = `${title} - ${siteConfig.title}`;
} else {
pageTitle = siteConfig.subtitle
? `${siteConfig.title} - ${siteConfig.subtitle}`
: siteConfig.title;
}
let ogImageUrl: string | undefined;
if (siteConfig.generateOgImages && postSlug) {
ogImageUrl = new URL(`/og/${postSlug}.png`, Astro.site).toString();
}
const favicons: Favicon[] =
siteConfig.favicon.length > 0 ? siteConfig.favicon : defaultFavicons;
// const siteLang = siteConfig.lang.replace('_', '-')
if (!lang) {
lang = `${siteConfig.lang}`;
}
const siteLang = lang.replace("_", "-");
const bannerOffsetByPosition = {
top: `${BANNER_HEIGHT_EXTEND}vh`,
center: `${BANNER_HEIGHT_EXTEND / 2}vh`,
bottom: "0",
};
const bannerOffset =
bannerOffsetByPosition[siteConfig.wallpaper.position || "center"];
const umamiEnabled = umamiConfig.enabled || false;
const umamiScripts = umamiConfig.scripts || ""; // 获取Umami scripts配置
---
<!DOCTYPE html>
<html lang={siteLang} class="bg-(--page-bg) text-[14px] md:text-[16px] is-loading"
>
<head>
<meta charset="UTF-8" />
<!-- Resource Preconnect -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://code.iconify.design">
<!-- Font Loader -->
<FontLoader />
<!-- title -->
<title>{pageTitle}</title>
<!-- description -->
<meta name="description" content={description || pageTitle}>
<!-- keywords -->
{siteConfig.keywords && siteConfig.keywords.length > 0 && (
<meta name="keywords" content={siteConfig.keywords.join(', ')} />
)}
<!-- author -->
<meta name="author" content={profileConfig.name}>
<!-- Open Graph -->
<meta property="og:site_name" content={siteConfig.title}>
<meta property="og:url" content={Astro.url}>
<meta property="og:title" content={pageTitle}>
<meta property="og:description" content={description || pageTitle}>
{ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
{
setOGTypeArticle ? (
<meta property="og:type" content="article" />
) : (
<meta property="og:type" content="website" />
)
}
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:url" content={Astro.url}>
<meta name="twitter:title" content={pageTitle}>
<meta name="twitter:description" content={description || pageTitle}>
<!-- viewport -->
<meta name="viewport" content="width=device-width" />
<!-- generator -->
<meta name="generator" content={Astro.generator} />
<!-- favicons -->
{favicons.map(favicon => (
<link rel="icon"
href={favicon.src.startsWith('/') ? url(favicon.src) : favicon.src}
sizes={favicon.sizes}
media={favicon.theme && `(prefers-color-scheme: ${favicon.theme})`}
/>
))}
<!-- Set the theme before the page is rendered to avoid a flash -->
<script is:inline define:vars={{LIGHT_MODE, DARK_MODE, SYSTEM_MODE, BANNER_HEIGHT_EXTEND, PAGE_WIDTH, NAVBAR_HEIGHT, configHue, defaultTheme: siteConfig.defaultTheme}}>
// Load the theme from local storage
const theme = localStorage.getItem('theme') || defaultTheme;
// Apply the theme to the document
let isDark = false;
switch (theme) {
case LIGHT_MODE:
document.documentElement.classList.remove('dark');
isDark = false;
break
case DARK_MODE:
document.documentElement.classList.add('dark');
isDark = true;
break
case SYSTEM_MODE:
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
isDark = true;
} else {
document.documentElement.classList.remove('dark');
isDark = false;
}
}
const expressiveTheme = isDark ? "github-dark" : "github-light";
document.documentElement.setAttribute("data-theme", expressiveTheme);
// Load the hue from local storage
const hue = localStorage.getItem('hue') || configHue;
// Apply the hue to the document
document.documentElement.style.setProperty('--hue', hue);
// calculate the --banner-height-extend, which needs to be a multiple of 4 to avoid blurry text
let offset = Math.floor(window.innerHeight * (BANNER_HEIGHT_EXTEND / 100));
offset = offset - offset % 4;
document.documentElement.style.setProperty('--banner-height-extend', `${offset}px`);
</script>
<!-- defines global css variables. This will be applied to <html> <body> and some other elements idk why -->
<style define:vars={{
configHue,
'page-width': `${PAGE_WIDTH}rem`,
}}></style>
<!-- custom head -->
<slot name="head"></slot>
<!-- Pio 看板娘样式 - 仅在启用时加载 -->
{pioConfig.enable && <link rel="stylesheet" href="/pio/static/pio.css" />}
<!-- rss feed -->
<link rel="alternate" type="application/rss+xml" title={profileConfig.name} href={`${Astro.site}rss.xml`}/>
<!-- Umami Analytics -->
{umamiEnabled && umamiScripts && <Fragment set:html={umamiScripts} />}
</head>
<body class=" min-h-screen " class:list={[{"is-home": isHomePage, "enable-banner": enableBanner}]}
>
<!-- 页面加载动画 -->
<LoadingOverlay />
<!-- 全局配置载体 -->
<ConfigCarrier />
<!-- 页面顶部渐变高光效果 - 只在full和semifull模式下显示 -->
{shouldShowTopHighlight && <div class="top-gradient-highlight"></div>}
<!-- 页面内容 -->
<slot />
<!-- Music Player - 仅在启用时加载 -->
{musicPlayerConfig.enable && <MusicPlayer client:idle />}
<!-- Pio 看板娘组件 - 如果启用了侧边栏,可能需要调整 z-index -->
{pioConfig.enable && <Pio client:idle />}
<!-- increase the page height during page transition to prevent the scrolling animation from jumping -->
<div id="page-height-extend" class="hidden h-[300vh]"></div>
</body>
</html>
<style is:global define:vars={{
bannerOffset,
'banner-height-home': `${BANNER_HEIGHT_HOME}vh`,
'banner-height': `${BANNER_HEIGHT}vh`,
}}>
@reference "../styles/main.css";
@layer components {
.enable-banner.is-home #banner-wrapper {
@apply h-(--banner-height-home) translate-y-(--banner-height-extend)
}
.enable-banner #banner-wrapper {
@apply h-(--banner-height-home)
}
.enable-banner.is-home #banner {
@apply h-(--banner-height-home) translate-y-0
}
.enable-banner #banner {
@apply h-(--banner-height-home) translate-y-(--bannerOffset)
}
.enable-banner.is-home #main-grid {
translate: 0 var(--banner-height-extend);
}
.enable-banner #top-row {
height: calc(var(--banner-height-home) - 5.5rem);
}
.enable-banner.is-home #sidebar-sticky {
top: calc(1rem - var(--banner-height-extend)) !important;
}
}
</style>
<script>
import {
NAVBAR_HEIGHT,
BANNER_HEIGHT,
BANNER_HEIGHT_HOME,
BANNER_HEIGHT_EXTEND,
} from "@/constants/constants";
import { pathsEqual, url } from "@utils/url";
import { initWallpaperMode } from "@utils/wallpaper";
import { initTheme } from "@utils/theme";
import { initHue } from "@utils/hue";
import { initMarkdownActions } from "@/utils/markdown";
import { initFancybox, cleanupFancybox } from "@utils/fancybox";
import { loadAndInitTranslate } from "@utils/language";
import { setupParticleEffects } from "@/utils/particle";
// 初始化Swup
const setup = () => {
// Swup can update the body for each page visit, but it's after the page transition, causing a delay for banner height change, so use Swup hooks instead to change the height immediately when a link is clicked
window.swup.hooks.on('link:click', () => {
// 简化 navbar 处理逻辑
const bannerEnabled = !!document.getElementById('banner-wrapper')
if (bannerEnabled) {
const navbar = document.getElementById('navbar-wrapper')
if (navbar && document.body.classList.contains('is-home')) {
const threshold = window.innerHeight * (BANNER_HEIGHT / 100) - NAVBAR_HEIGHT
if (document.documentElement.scrollTop >= threshold) {
navbar.classList.add('navbar-hidden')
}
}
}
})
window.swup.hooks.on('content:replace', () => {
// Update main grid layout class name after page transition
const gridClassCarrier = document.getElementById("grid-class-carrier");
if (gridClassCarrier) {
const gridClass = gridClassCarrier.getAttribute("data-grid-class");
const mainGrid = document.getElementById("main-grid");
if (mainGrid && gridClass) {
const currentClasses = mainGrid.className.split(" ");
const newClasses = currentClasses.filter(c => !c.startsWith("lg:grid-cols-") && !c.startsWith("md:grid-cols-") && !c.startsWith("grid-cols-"));
const gridClasses = gridClass.split(" ");
mainGrid.className = [...newClasses, ...gridClasses].join(" ");
}
}
// Reinitialize semifull mode scroll detection after page transition
const navbar = document.getElementById('navbar')
if (navbar) {
const transparentMode = navbar.getAttribute('data-transparent-mode')
if (transparentMode === 'semifull') {
// Re-call the initialization function to rebind scroll events
if (typeof (window as any).initSemifullScrollDetection === 'function') {
(window as any).initSemifullScrollDetection()
}
}
}
})
window.swup.hooks.on('visit:start', (visit: {to: {url: string}}) => {
// Disable scroll protection when a link is clicked to prevent interference with page jump scrolling to top
if ((window as any).scrollProtectionManager && typeof (window as any).scrollProtectionManager.disable === 'function') {
(window as any).scrollProtectionManager.disable();
}
// Change banner height immediately when a link is clicked
const bodyElement = document.querySelector('body')
const isHomePage = pathsEqual(visit.to.url, url('/'))
if (isHomePage) {
bodyElement!.classList.add('is-home');
} else {
bodyElement!.classList.remove('is-home');
}
// Control banner text visibility based on page
const bannerTextOverlay = document.querySelector('.banner-text-overlay')
if (bannerTextOverlay) {
if (isHomePage) {
bannerTextOverlay.classList.remove('hidden')
} else {
bannerTextOverlay.classList.add('hidden')
}
}
// Control navbar transparency based on page
const navbar = document.getElementById('navbar')
if (navbar) {
navbar.setAttribute('data-is-home', isHomePage.toString())
const transparentMode = navbar.getAttribute('data-transparent-mode')
if (transparentMode === 'semifull') {
// Re-call the initialization function to rebind scroll events
if (typeof window.initSemifullScrollDetection === 'function') {
window.initSemifullScrollDetection()
}
}
}
// increase the page height during page transition to prevent the scrolling animation from jumping
const heightExtend = document.getElementById('page-height-extend')
if (heightExtend) {
heightExtend.classList.remove('hidden')
}
});
window.swup.hooks.on('visit:end', (_visit: {to: {url: string}}) => {
setTimeout(() => {
const heightExtend = document.getElementById('page-height-extend')
if (heightExtend) {
heightExtend.classList.add('hidden')
}
}, 300)
});
window.swup.hooks.on('page:view', () => {
// Scroll to top of the page after page transition
window.scrollTo({
top: 0,
behavior: 'smooth'
});
// Check if the current page is a post page, if so, trigger a custom event to initialize the comment system
setTimeout(() => {
if (document.getElementById('tcomment')) {
const pageLoadedEvent = new CustomEvent('twilight:page:loaded', {
detail: {
path: window.location.pathname,
timestamp: Date.now()
}
});
document.dispatchEvent(pageLoadedEvent);
}
}, 300);
// Initialize Fancybox
initFancybox();
});
initFancybox();
window.swup.hooks.on("content:replace", () => {
cleanupFancybox();
},
{ before: true },
)
}
if (window?.swup?.hooks) {
setup()
} else {
document.addEventListener('swup:enable', setup)
}
// 初始化 navbar 元素,用于控制 banner 隐藏
let navbar = document.getElementById('navbar-wrapper')
// 初始化返回顶部按钮元素,用于控制返回顶部按钮的显示与隐藏
let backToTopBtn = document.getElementById('back-to-top-btn');
// 节流函数
function throttle(func: (...args: any[]) => void, limit: number) {
let inThrottle: boolean;
return function(this: any, ...args: any[]) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
}
}
// 滚动事件处理函数用于控制返回顶部按钮和banner隐藏
function scrollFunction() {
const scrollTop = document.documentElement.scrollTop;
const bannerHeight = window.innerHeight * (BANNER_HEIGHT / 100);
const bannerEnabled = !!document.getElementById('banner-wrapper')
// 批量处理DOM操作
requestAnimationFrame(() => {
if (backToTopBtn) {
if (scrollTop > bannerHeight) {
backToTopBtn.classList.remove('hide')
} else {
backToTopBtn.classList.add('hide')
}
}
if (bannerEnabled && navbar) {
const isHome = document.body.classList.contains('is-home');
const currentBannerHeight = isHome ? BANNER_HEIGHT_HOME : BANNER_HEIGHT;
const threshold = window.innerHeight * (currentBannerHeight / 100) - NAVBAR_HEIGHT;
if (scrollTop >= threshold) {
navbar.classList.add('navbar-hidden')
} else {
navbar.classList.remove('navbar-hidden')
}
}
});
}
// 使用节流优化滚动性能
window.onscroll = throttle(scrollFunction, 16); // 约60fps
// 监听窗口 resize 事件,用于更新 banner 高度相关的 CSS 变量
window.onresize = () => {
// calculate the --banner-height-extend, which needs to be a multiple of 4 to avoid blurry text
let offset = Math.floor(window.innerHeight * (BANNER_HEIGHT_EXTEND / 100));
offset = offset - offset % 4;
document.documentElement.style.setProperty('--banner-height-extend', `${offset}px`);
}
// Initialize theme
initTheme();
// Initialize hue
initHue();
// Initialize wallpaper mode
initWallpaperMode();
// 初始化 Markdown 相关操作以防止因 Swup 无刷新跳转的机制限制而导致操作失效
initMarkdownActions();
// Initialize translate.js
loadAndInitTranslate();
// Initialize particle effects
setupParticleEffects();
// Remove is-loading class and trigger initial-animation after page load
if (typeof window !== 'undefined') {
window.addEventListener('load', () => {
const overlay = document.getElementById('loading-overlay');
const enableBanner = document.body.classList.contains('enable-banner');
// 触发 LoadingOverlay 淡出
if (overlay) {
overlay.classList.add('fade-out');
}
// 等待 LoadingOverlay 淡出600ms
setTimeout(() => {
if (overlay) {
overlay.style.display = 'none';
}
if (enableBanner) {
// banner 开始恢复过渡:添加 banner-restoring 类(解除 CSS 中的 height: 0
document.documentElement.classList.add('banner-restoring');
// 等待 banner 恢复过渡完成600ms
setTimeout(() => {
// 触发各容器淡入动画
document.documentElement.classList.remove('is-loading');
document.documentElement.classList.add('show-initial-animation');
setTimeout(() => {
document.documentElement.classList.remove('show-initial-animation');
document.documentElement.classList.remove('banner-restoring');
}, 1200);
}, 600);
} else {
// 非 banner 模式,直接触发淡入
document.documentElement.classList.remove('is-loading');
document.documentElement.classList.add('show-initial-animation');
setTimeout(() => {
document.documentElement.classList.remove('show-initial-animation');
}, 1200);
}
}, 600);
});
}
</script>