app-template/app/web/src/layouts/AppLayout.astro

243 lines
8.8 KiB
Plaintext

---
import { ClientRouter } from "astro:transitions";
import "../styles/globals.css";
import UserMenu from "~/components/UserMenu";
import ThemeSwitch from "~/components/ThemeSwitch";
import LocaleSwitch from "~/components/LocaleSwitch";
import {
buildDefaultJsonLd,
buildHeadTags,
renderHeadTagsHtml,
toJsonLdScript,
type PageSeoInput,
} from "@app/seo-geo";
import {
alternatePath,
COOKIE_LANG,
COOKIE_LANG_MAX_AGE,
LOCALES,
type Locale,
localePath,
t,
} from "@app/i18n";
import { getSiteSeo } from "~/lib/seo";
export interface Props {
title: string;
locale: Locale;
brand?: string;
/** 当前页,用于侧栏高亮 */
active?: "home" | "library" | "browse" | "mounts" | "danmaku";
seo?: PageSeoInput;
/** 播放页隐藏模板页脚 */
hideFooter?: boolean;
}
const { title, locale, brand, active, seo, hideFooter = false } = Astro.props;
/** 语言下拉:各语言在当前页的 URL */
const localeHrefs = Object.fromEntries(
LOCALES.map((code) => [code, alternatePath(Astro.url.pathname, code)]),
) as Record<Locale, string>;
const brandName = brand ?? t(locale, "meta.brand");
const siteSeo = getSiteSeo(locale);
const pageSeoInput: PageSeoInput = {
...(seo ?? {}),
title: seo?.title ?? title,
path: seo?.path ?? Astro.url.pathname,
};
const headTags = buildHeadTags(siteSeo, pageSeoInput);
const headHtml = renderHeadTagsHtml(headTags);
const jsonLdHtml = toJsonLdScript(buildDefaultJsonLd(siteSeo, pageSeoInput));
const pageTitle = headTags.find((tTag) => tTag.key === "og:title")?.value ?? title;
const homeHref = localePath(locale, "/");
const brandHref = homeHref;
const brandInitial = brandName.charAt(0) || "弹";
/** 全站统一侧栏导航 */
const nav = [
{ id: "home", n: "00", label: t(locale, "nav.home"), href: homeHref },
{ id: "library", n: "01", label: t(locale, "nav.library"), href: localePath(locale, "/library") },
{ id: "browse", n: "02", label: t(locale, "nav.browse"), href: localePath(locale, "/browse") },
{ id: "mounts", n: "03", label: t(locale, "nav.mounts"), href: localePath(locale, "/mounts") },
{ id: "danmaku", n: "04", label: t(locale, "nav.danmaku"), href: localePath(locale, "/danmaku") },
] as const;
---
<!doctype html>
<html lang={locale} class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Noto+Sans+SC:wght@400;500;600;700;900&family=Space+Grotesk:wght@500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="alternate" type="text/plain" href="/llms.txt" title="llms.txt" />
<link rel="alternate" hreflang="zh-CN" href={`${siteSeo.siteUrl}/zh-CN`} />
<link rel="alternate" hreflang="en" href={`${siteSeo.siteUrl}/en`} />
<link rel="alternate" hreflang="x-default" href={`${siteSeo.siteUrl}/zh-CN`} />
<title set:html={pageTitle} />
<Fragment set:html={headHtml} />
<Fragment set:html={jsonLdHtml} />
<ClientRouter />
{/* ClientRouter onPopState 在 history.state 为 null 时会吞掉前进/后退,见 history-guard */}
<script>
import "@/lib/history-guard";
</script>
<script is:inline define:vars={{ cookieName: COOKIE_LANG, maxAge: COOKIE_LANG_MAX_AGE, nextLocale: locale === "zh-CN" ? "en" : "zh-CN" }}>
(function () {
function apply(mode) {
var dark =
mode === "dark" ||
(mode !== "light" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", dark);
document.documentElement.classList.toggle("light", !dark);
document.documentElement.style.colorScheme = dark ? "dark" : "light";
}
function applyFromStorage() {
var mode = "system";
try {
var t = localStorage.getItem("app-theme");
if (t === "light" || t === "dark" || t === "system") mode = t;
} catch (e) {}
apply(mode);
}
applyFromStorage();
// ClientRouter 软导航后 <html> class 会被新页覆盖,需按偏好重放主题
document.addEventListener("astro:after-swap", applyFromStorage);
if (!window.__fanmuChromeInit) {
window.__fanmuChromeInit = true;
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", function () {
try {
if ((localStorage.getItem("app-theme") || "system") === "system") {
apply("system");
}
} catch (e) {
apply("system");
}
});
// 语言切换:写偏好 cookie 后由 <a> 跳转(捕获阶段)
document.addEventListener(
"click",
function (e) {
var el = e.target && e.target.closest ? e.target.closest("[data-switch-locale]") : null;
if (!el) return;
var target = el.getAttribute("data-switch-locale") || nextLocale;
document.cookie =
cookieName + "=" + target + "; path=/; max-age=" + maxAge + "; SameSite=Lax";
},
true,
);
}
})();
</script>
</head>
<body>
<div aria-hidden="true" class="bg-base pointer-events-none fixed inset-0 z-0"></div>
<div
aria-hidden="true"
class="u-grid-field pointer-events-none fixed inset-0 z-0"
style="opacity: 0.18;"
>
</div>
<div aria-hidden="true" class="bg-glow pointer-events-none fixed inset-0 z-0"></div>
<div aria-hidden="true" class="bg-vignette pointer-events-none fixed inset-0 z-0"></div>
{
<aside class="side-rail" aria-label={t(locale, "nav.primary")}>
<a href={brandHref} class="side-rail__brand" aria-label={brandName}>
<span
class="brand-signal flex size-8 items-center justify-center text-[12px] font-bold"
style="background: var(--edge); color: #101110;"
>
{brandInitial}
</span>
<span class="side-rail__brand-text">{brandName}</span>
</a>
<div class="side-rail__rule" aria-hidden="true"></div>
<nav class="side-rail__nav">
{nav.map((item) => (
<a
href={item.href}
class:list={["side-rail__link", { "is-active": active === item.id }]}
aria-current={active === item.id ? "page" : undefined}
>
<span class="u-mono">{item.n}</span>
<span>{item.label}</span>
</a>
))}
</nav>
<div class="side-rail__rule" aria-hidden="true"></div>
<div class="side-rail__foot">
<ThemeSwitch client:only="react" />
</div>
</aside>
}
<div class="site-shell">
<header class="site-header">
<div class="site-header__inner">
<a href={brandHref} class="site-header__brand lg:hidden" aria-label={brandName}>
<span
class="brand-signal flex size-7 items-center justify-center text-[11px] font-bold"
style="background: var(--edge); color: #101110;"
>
{brandInitial}
</span>
<span class="text-[14px] font-semibold tracking-[0.04em]">{brandName}</span>
</a>
<nav class="site-header__nav" aria-label={t(locale, "nav.primary")}>
{
nav.map((item) => (
<a
href={item.href}
class:list={["u-linelink", { "is-active": active === item.id }]}
aria-current={active === item.id ? "page" : undefined}
>
<span class="u-mono">{item.n}</span>
<span>{item.label}</span>
</a>
))
}
</nav>
<div class="site-header__actions">
<LocaleSwitch client:only="react" locale={locale} hrefs={localeHrefs} />
<span class="lg:hidden">
<ThemeSwitch client:only="react" />
</span>
<UserMenu client:only="react" locale={locale} />
</div>
</div>
<div class="site-header__dash" aria-hidden="true"></div>
</header>
<main class="page-main">
<slot />
</main>
{!hideFooter && (
<footer class="site-footer">
<div class="u-dotline" aria-hidden="true"></div>
<div class="site-footer__inner">
<p class="u-mono">{brandName}</p>
<p class="u-label">{t(locale, "home.footer")}</p>
</div>
</footer>
)}
</div>
</body>
</html>