提升网页渲染性能,取消织纹效果和模糊效果
This commit is contained in:
@@ -19,6 +19,8 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { openModalFocus } from "@/lib/modal-focus"
|
||||||
|
|
||||||
const LB_ID = "vv-lightbox"
|
const LB_ID = "vv-lightbox"
|
||||||
|
|
||||||
const ensureOverlay = (): HTMLElement => {
|
const ensureOverlay = (): HTMLElement => {
|
||||||
@@ -29,8 +31,10 @@
|
|||||||
overlay.id = LB_ID
|
overlay.id = LB_ID
|
||||||
overlay.setAttribute("role", "dialog")
|
overlay.setAttribute("role", "dialog")
|
||||||
overlay.setAttribute("aria-modal", "true")
|
overlay.setAttribute("aria-modal", "true")
|
||||||
|
overlay.setAttribute("aria-label", "图片预览")
|
||||||
|
overlay.tabIndex = -1
|
||||||
overlay.className =
|
overlay.className =
|
||||||
"fixed inset-0 z-[100] hidden items-center justify-center bg-black/90 backdrop-blur-sm"
|
"fixed inset-0 z-[100] hidden items-center justify-center bg-black/90"
|
||||||
overlay.innerHTML = `
|
overlay.innerHTML = `
|
||||||
<button type="button" data-lb-close aria-label="关闭"
|
<button type="button" data-lb-close aria-label="关闭"
|
||||||
class="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-2xl leading-none text-white/90 transition-colors hover:bg-white/20">×</button>
|
class="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-2xl leading-none text-white/90 transition-colors hover:bg-white/20">×</button>
|
||||||
@@ -49,6 +53,8 @@
|
|||||||
let sources: string[] = []
|
let sources: string[] = []
|
||||||
let alts: string[] = []
|
let alts: string[] = []
|
||||||
let current = 0
|
let current = 0
|
||||||
|
/** 关闭时用来还原背景 inert 与焦点 */
|
||||||
|
let releaseFocus: (() => void) | null = null
|
||||||
|
|
||||||
const render = (overlay: HTMLElement) => {
|
const render = (overlay: HTMLElement) => {
|
||||||
const img = overlay.querySelector<HTMLImageElement>("[data-lb-img]")
|
const img = overlay.querySelector<HTMLImageElement>("[data-lb-img]")
|
||||||
@@ -65,13 +71,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openAt = (index: number) => {
|
const openAt = (index: number, trigger?: HTMLElement | null) => {
|
||||||
const overlay = ensureOverlay()
|
const overlay = ensureOverlay()
|
||||||
current = index
|
current = index
|
||||||
render(overlay)
|
render(overlay)
|
||||||
overlay.classList.remove("hidden")
|
overlay.classList.remove("hidden")
|
||||||
overlay.classList.add("flex")
|
overlay.classList.add("flex")
|
||||||
document.body.style.overflow = "hidden"
|
document.body.style.overflow = "hidden"
|
||||||
|
|
||||||
|
// 遮罩声明了 role="dialog" / aria-modal="true",就得真做到两件事:
|
||||||
|
// 把焦点移进来、并让背景不可交互。否则键盘用户 Tab 到的是被盖住的正文,
|
||||||
|
// 那三个按钮(关闭/上一张/下一张)根本够不着。
|
||||||
|
releaseFocus?.()
|
||||||
|
releaseFocus = openModalFocus(overlay, {
|
||||||
|
initialFocus: overlay.querySelector<HTMLElement>("[data-lb-close]"),
|
||||||
|
restoreFocus: trigger ?? null,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
@@ -80,6 +95,9 @@
|
|||||||
overlay.classList.add("hidden")
|
overlay.classList.add("hidden")
|
||||||
overlay.classList.remove("flex")
|
overlay.classList.remove("flex")
|
||||||
document.body.style.overflow = ""
|
document.body.style.overflow = ""
|
||||||
|
// 还原背景 inert,并把焦点还给打开灯箱的那个元素
|
||||||
|
releaseFocus?.()
|
||||||
|
releaseFocus = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const step = (delta: number) => {
|
const step = (delta: number) => {
|
||||||
@@ -101,7 +119,7 @@
|
|||||||
const all = Array.from(document.querySelectorAll<HTMLElement>("[data-lightbox-src]"))
|
const all = Array.from(document.querySelectorAll<HTMLElement>("[data-lightbox-src]"))
|
||||||
sources = all.map((el) => el.dataset.lightboxSrc ?? "")
|
sources = all.map((el) => el.dataset.lightboxSrc ?? "")
|
||||||
alts = all.map((el) => (el instanceof HTMLImageElement ? el.alt : ""))
|
alts = all.map((el) => (el instanceof HTMLImageElement ? el.alt : ""))
|
||||||
openAt(Number(trigger.dataset.lightboxIndex ?? all.indexOf(trigger)))
|
openAt(Number(trigger.dataset.lightboxIndex ?? all.indexOf(trigger)), trigger)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +142,9 @@
|
|||||||
|
|
||||||
// 切页时收拾干净,避免残留遮罩锁住滚动
|
// 切页时收拾干净,避免残留遮罩锁住滚动
|
||||||
document.addEventListener("astro:page-load", () => {
|
document.addEventListener("astro:page-load", () => {
|
||||||
|
// 先还原 inert / 焦点,再移除遮罩,别把上一页的 inert 状态留在新页面上
|
||||||
|
releaseFocus?.()
|
||||||
|
releaseFocus = null
|
||||||
const overlay = document.getElementById(LB_ID)
|
const overlay = document.getElementById(LB_ID)
|
||||||
if (overlay) overlay.remove()
|
if (overlay) overlay.remove()
|
||||||
document.body.style.overflow = ""
|
document.body.style.overflow = ""
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ const encryptedDataString = encryptedData ? JSON.stringify(encryptedData) : "";
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
backdrop-filter: blur(5px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.password-container {
|
.password-container {
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ export function ArticleList({
|
|||||||
const filterPillOff = "border-border text-muted-foreground hover:border-primary hover:text-primary"
|
const filterPillOff = "border-border text-muted-foreground hover:border-primary hover:text-primary"
|
||||||
|
|
||||||
const filterBar = (
|
const filterBar = (
|
||||||
<div className="lg:hidden sticky top-[var(--header-h)] z-30 -mx-6 mb-8 border-b border-border/60 bg-surface-subtle/80 backdrop-blur-md">
|
<div className="lg:hidden sticky top-[var(--header-h)] z-30 -mx-6 mb-8 border-b border-border/60 bg-surface-subtle">
|
||||||
<div
|
<div
|
||||||
ref={filterBarRef}
|
ref={filterBarRef}
|
||||||
className="no-scrollbar flex items-center gap-2 overflow-x-auto px-6 py-2.5"
|
className="no-scrollbar flex items-center gap-2 overflow-x-auto px-6 py-2.5"
|
||||||
@@ -425,7 +425,13 @@ export function ArticleList({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section ref={sectionRef} className="article-list-root px-6 py-16 bg-surface-subtle">
|
// 手机端(<lg)上方不留内边距:列表的第一个元素是吸顶胶囊条,
|
||||||
|
// 让它紧贴 section 顶端,配合 #home-main 的 scroll-margin 正好落在页眉下沿;
|
||||||
|
// 桌面端没有吸顶条,保留 py-16 的呼吸位。
|
||||||
|
<section
|
||||||
|
ref={sectionRef}
|
||||||
|
className="article-list-root px-6 pt-0 pb-16 lg:py-16 bg-surface-subtle"
|
||||||
|
>
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
{filterBar}
|
{filterBar}
|
||||||
{/* Section header */}
|
{/* Section header */}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export function HeaderSearch() {
|
|||||||
const [isFocused, setIsFocused] = useState(false)
|
const [isFocused, setIsFocused] = useState(false)
|
||||||
const [status, setStatus] = useState<"idle" | "loading" | "ready" | "error">("idle")
|
const [status, setStatus] = useState<"idle" | "loading" | "ready" | "error">("idle")
|
||||||
const [loadRequested, setLoadRequested] = useState(false)
|
const [loadRequested, setLoadRequested] = useState(false)
|
||||||
|
/** 查询进行中(pagefind 已可用,正在等结果) */
|
||||||
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
|
/** 这次查询抛了异常。与 status 分开:status 描述「pagefind 加载」,它描述「本次查询」 */
|
||||||
|
const [failed, setFailed] = useState(false)
|
||||||
const isProd = import.meta.env.PROD
|
const isProd = import.meta.env.PROD
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const desktopInputRef = useRef<HTMLInputElement>(null)
|
const desktopInputRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -90,13 +94,22 @@ export function HeaderSearch() {
|
|||||||
|
|
||||||
const handleLoaded = () => {
|
const handleLoaded = () => {
|
||||||
if (!cancelled) setStatus("ready")
|
if (!cancelled) setStatus("ready")
|
||||||
|
window.clearTimeout(watchdog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 兜底:pagefind.js 是运行时 import 的,模块加载失败**不会**触发 script.onerror,
|
||||||
|
// 只靠上面那个 onerror 会出现「永远搜索中…」。超过 8 秒还没就绪就按失败处理。
|
||||||
|
const watchdog = window.setTimeout(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setStatus((current) => (current === "loading" ? "error" : current))
|
||||||
|
}, 8000)
|
||||||
|
|
||||||
window.addEventListener("pagefind:loaded", handleLoaded)
|
window.addEventListener("pagefind:loaded", handleLoaded)
|
||||||
loadPagefind()
|
loadPagefind()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
window.clearTimeout(watchdog)
|
||||||
window.removeEventListener("pagefind:loaded", handleLoaded)
|
window.removeEventListener("pagefind:loaded", handleLoaded)
|
||||||
}
|
}
|
||||||
}, [isProd, loadRequested])
|
}, [isProd, loadRequested])
|
||||||
@@ -118,12 +131,20 @@ export function HeaderSearch() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { results: searchResults } = await window.pagefind!.search(query)
|
const { results: searchResults } = await window.pagefind!.search(query)
|
||||||
setResults(searchResults.slice(0, 8)) // Limit results
|
setResults(searchResults.slice(0, 8)) // 只展示前 8 条
|
||||||
|
setFailed(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Pagefind search failed:", error)
|
console.error("Pagefind search failed:", error)
|
||||||
|
setResults([])
|
||||||
|
setFailed(true)
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 一有输入就进「搜索中」:把 200ms 防抖窗口也算进去,
|
||||||
|
// 否则这 200ms 里会先闪一下「未找到相关结果」。
|
||||||
|
setIsSearching(Boolean(query.trim()))
|
||||||
const debounce = setTimeout(runSearch, 200)
|
const debounce = setTimeout(runSearch, 200)
|
||||||
return () => clearTimeout(debounce)
|
return () => clearTimeout(debounce)
|
||||||
}, [query, isProd, status, requestPagefindLoad])
|
}, [query, isProd, status, requestPagefindLoad])
|
||||||
@@ -147,6 +168,11 @@ export function HeaderSearch() {
|
|||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
setIsFocused(false)
|
setIsFocused(false)
|
||||||
|
// 让搜索框自己失焦:否则面板藏起来后继续打字不会再触发 onFocus,看着像搜索坏了
|
||||||
|
const active = document.activeElement
|
||||||
|
if (active === desktopInputRef.current || active === mobileInputRef.current) {
|
||||||
|
;(active as HTMLElement).blur()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +206,22 @@ export function HeaderSearch() {
|
|||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 面板里该显示什么。
|
||||||
|
*
|
||||||
|
* 原来只判断 `results.length === 0`,于是「pagefind 还在加载」和「查询失败」都会
|
||||||
|
* 显示成「未找到相关结果」—— 把一条确定性错误信息递给用户,直接劝退。
|
||||||
|
*/
|
||||||
|
const searchState: "idle" | "loading" | "empty" | "error" | "results" = !query.trim()
|
||||||
|
? "idle"
|
||||||
|
: failed || status === "error"
|
||||||
|
? "error"
|
||||||
|
: status === "loading" || isSearching
|
||||||
|
? "loading"
|
||||||
|
: results.length === 0
|
||||||
|
? "empty"
|
||||||
|
: "results"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative">
|
<div ref={containerRef} className="relative">
|
||||||
{overlay}
|
{overlay}
|
||||||
@@ -227,12 +269,18 @@ export function HeaderSearch() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Search panel */}
|
{/* Search panel */}
|
||||||
<div className={cn(
|
<div
|
||||||
|
// 关闭时必须 inert:opacity-0 / pointer-events-none 都挡不住键盘,
|
||||||
|
// 否则那几条结果链接会变成「看不见但 Tab 得到」的焦点陷阱。
|
||||||
|
inert={!showPanel}
|
||||||
|
aria-hidden={!showPanel}
|
||||||
|
className={cn(
|
||||||
"fixed left-4 right-4 top-16 md:absolute md:top-full md:left-auto md:right-0 md:w-96 md:mt-2 z-50",
|
"fixed left-4 right-4 top-16 md:absolute md:top-full md:left-auto md:right-0 md:w-96 md:mt-2 z-50",
|
||||||
"bg-background/95 backdrop-blur-xl border border-border rounded-xl shadow-lg",
|
"bg-background border border-border rounded-xl shadow-lg",
|
||||||
"transition-all duration-200 overflow-hidden",
|
"transition-all duration-200 overflow-hidden",
|
||||||
showPanel ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2 pointer-events-none"
|
showPanel ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2 pointer-events-none"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
{/* Mobile search input inside panel */}
|
{/* Mobile search input inside panel */}
|
||||||
<div className="md:hidden p-3 border-b border-border">
|
<div className="md:hidden p-3 border-b border-border">
|
||||||
<div className="flex items-center h-10 rounded-lg bg-muted/50">
|
<div className="flex items-center h-10 rounded-lg bg-muted/50">
|
||||||
@@ -261,8 +309,20 @@ export function HeaderSearch() {
|
|||||||
|
|
||||||
{/* Search results */}
|
{/* Search results */}
|
||||||
<div className="max-h-80 overflow-y-auto">
|
<div className="max-h-80 overflow-y-auto">
|
||||||
{isProd && query.trim() && results.length === 0 && (
|
{isProd && searchState === "loading" && (
|
||||||
<div className="p-4 text-sm text-muted-foreground text-center">
|
<div role="status" className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
搜索中…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isProd && searchState === "error" && (
|
||||||
|
<div role="status" className="p-4 text-center text-sm text-destructive">
|
||||||
|
搜索暂时不可用,请刷新页面重试
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isProd && searchState === "empty" && (
|
||||||
|
<div role="status" className="p-4 text-center text-sm text-muted-foreground">
|
||||||
未找到相关结果
|
未找到相关结果
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const isCurrent = (href: string) =>
|
|||||||
href === "/#home-main" ? currentPath === "/" : currentPath.startsWith(href)
|
href === "/#home-main" ? currentPath === "/" : currentPath.startsWith(href)
|
||||||
---
|
---
|
||||||
|
|
||||||
<header class="site-header fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-xl border-b border-border/50 py-4">
|
<header class="site-header fixed top-0 left-0 right-0 z-50 bg-background border-b border-border/50 py-4">
|
||||||
<div class="max-w-6xl mx-auto px-6 grid grid-cols-[1fr_auto_1fr] items-center">
|
<div class="max-w-6xl mx-auto px-6 grid grid-cols-[1fr_auto_1fr] items-center">
|
||||||
<a href="/#home-main" data-astro-prefetch class="group flex items-center gap-3 justify-self-start">
|
<a href="/#home-main" data-astro-prefetch class="group flex items-center gap-3 justify-self-start">
|
||||||
<div class="w-8 h-8 rounded-full overflow-hidden border border-primary/20 bg-secondary transition-transform duration-300 group-hover:scale-105">
|
<div class="w-8 h-8 rounded-full overflow-hidden border border-primary/20 bg-secondary transition-transform duration-300 group-hover:scale-105">
|
||||||
@@ -84,6 +84,7 @@ const isCurrent = (href: string) =>
|
|||||||
id="mobile-menu"
|
id="mobile-menu"
|
||||||
class="mobile-menu md:hidden fixed inset-0 z-[60] pointer-events-none"
|
class="mobile-menu md:hidden fixed inset-0 z-[60] pointer-events-none"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<div class="mobile-menu-bg absolute inset-0" data-dismiss aria-hidden="true"></div>
|
<div class="mobile-menu-bg absolute inset-0" data-dismiss aria-hidden="true"></div>
|
||||||
|
|
||||||
@@ -167,16 +168,15 @@ const isCurrent = (href: string) =>
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 悬浮面板:磨砂卡片,从顶部轻轻落下 */
|
/* 悬浮面板:从顶部轻轻落下。
|
||||||
|
注意这是**不透明**卡片,不是磨砂卡片 —— 常驻 backdrop-filter 的成本见文件末尾说明。 */
|
||||||
.mobile-menu-panel {
|
.mobile-menu-panel {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-10px) scale(0.985);
|
transform: translateY(-10px) scale(0.985);
|
||||||
transform-origin: top right;
|
transform-origin: top right;
|
||||||
background-color: var(--glass-bg);
|
background-color: var(--card);
|
||||||
border-color: var(--glass-border);
|
border-color: var(--border);
|
||||||
box-shadow: 0 18px 48px -14px oklch(0 0 0 / 0.24);
|
box-shadow: 0 18px 48px -14px oklch(0 0 0 / 0.24);
|
||||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
|
||||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
|
||||||
transition:
|
transition:
|
||||||
opacity 180ms ease-out,
|
opacity 180ms ease-out,
|
||||||
transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
|
transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
@@ -237,22 +237,6 @@ const isCurrent = (href: string) =>
|
|||||||
transform: translateY(-50%) scaleY(1);
|
transform: translateY(-50%) scaleY(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 不支持 backdrop-filter 的浏览器:退回不透明卡片,别让文字糊在内容上 */
|
|
||||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
|
||||||
.mobile-menu-panel {
|
|
||||||
background-color: var(--card);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 系统开启“减少透明度”时直接关掉磨砂 */
|
|
||||||
@media (prefers-reduced-transparency: reduce) {
|
|
||||||
.mobile-menu-panel {
|
|
||||||
background-color: var(--card);
|
|
||||||
backdrop-filter: none;
|
|
||||||
-webkit-backdrop-filter: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.mobile-menu,
|
.mobile-menu,
|
||||||
.mobile-menu-panel,
|
.mobile-menu-panel,
|
||||||
@@ -272,18 +256,20 @@ const isCurrent = (href: string) =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 小屏上页眉不再常驻 backdrop-filter:固定元素持续模糊会让移动端合成开销变大,
|
/*
|
||||||
顶部时用实色反而更省更快。菜单面板是点开才出现的、面积也小,保留磨砂。 */
|
* 页眉与菜单面板都不再用 backdrop-filter(2026-09-20)。
|
||||||
@media (max-width: 767px) {
|
*
|
||||||
.site-header {
|
* 原来只在 max-width: 767px 关掉页眉的模糊,iPad 宽度 ≥768px 正好落在外面,
|
||||||
backdrop-filter: none !important;
|
* 于是固定页眉整幅宽度、每帧重算 24px 模糊 —— iPad Air 4 上滚动最重的文章页时
|
||||||
-webkit-backdrop-filter: none !important;
|
* 掉帧并出现未绘制的空白区域。现在全局改实色,顺带也不再需要
|
||||||
background-color: var(--background) !important;
|
* 「不支持 backdrop-filter 的降级」和「prefers-reduced-transparency」两段覆盖。
|
||||||
}
|
* 菜单面板原来是 --glass-bg(72% 透明),去掉模糊后文字会糊在内容上,一并改不透明。
|
||||||
}
|
*/
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { openModalFocus } from "@/lib/modal-focus"
|
||||||
|
|
||||||
// Runs on initial page load AND after every Astro View Transition navigation.
|
// Runs on initial page load AND after every Astro View Transition navigation.
|
||||||
// DOM nodes are replaced across navigations, so we must rebind listeners each time.
|
// DOM nodes are replaced across navigations, so we must rebind listeners each time.
|
||||||
const initMobileMenu = () => {
|
const initMobileMenu = () => {
|
||||||
@@ -296,6 +282,12 @@ const isCurrent = (href: string) =>
|
|||||||
toggle.dataset.menuBound = "1"
|
toggle.dataset.menuBound = "1"
|
||||||
|
|
||||||
let prevOverflow = ""
|
let prevOverflow = ""
|
||||||
|
/** 关闭时用来还原背景 inert 与焦点 */
|
||||||
|
let releaseFocus: (() => void) | null = null
|
||||||
|
|
||||||
|
// 关闭态必须是 inert,而不是只把 opacity 调成 0:
|
||||||
|
// opacity / pointer-events 都不影响键盘可聚焦性,透明面板会白吃掉 10 个 Tab 停靠点。
|
||||||
|
if (menu.dataset.open !== "true") menu.inert = true
|
||||||
|
|
||||||
const setOpen = (open: boolean) => {
|
const setOpen = (open: boolean) => {
|
||||||
const isOpen = menu.dataset.open === "true"
|
const isOpen = menu.dataset.open === "true"
|
||||||
@@ -308,13 +300,19 @@ const isCurrent = (href: string) =>
|
|||||||
if (open) {
|
if (open) {
|
||||||
prevOverflow = document.body.style.overflow
|
prevOverflow = document.body.style.overflow
|
||||||
document.body.style.overflow = "hidden"
|
document.body.style.overflow = "hidden"
|
||||||
const firstLink = menu.querySelector<HTMLAnchorElement>("nav a")
|
menu.inert = false
|
||||||
if (firstLink) {
|
// 背景 inert 之后 Tab 只能在菜单内部循环,不用再手写焦点陷阱
|
||||||
window.setTimeout(() => firstLink.focus({ preventScroll: true }), 100)
|
releaseFocus = openModalFocus(menu, {
|
||||||
}
|
initialFocus: menu.querySelector<HTMLAnchorElement>("nav a"),
|
||||||
|
restoreFocus: toggle,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
document.body.style.overflow = prevOverflow
|
document.body.style.overflow = prevOverflow
|
||||||
toggle.focus({ preventScroll: true })
|
// 顺序要紧:先还原背景的 inert(展开按钮在背景里)并把焦点还回去,
|
||||||
|
// 再把菜单本身设回 inert
|
||||||
|
releaseFocus?.()
|
||||||
|
releaseFocus = null
|
||||||
|
menu.inert = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,9 +349,12 @@ const isCurrent = (href: string) =>
|
|||||||
|
|
||||||
// Reset body scroll lock on navigation away while menu is open
|
// Reset body scroll lock on navigation away while menu is open
|
||||||
document.addEventListener("astro:before-swap", () => {
|
document.addEventListener("astro:before-swap", () => {
|
||||||
const menu = document.getElementById("mobile-menu")
|
const menu = document.getElementById("mobile-menu") as
|
||||||
|
| (HTMLElement & { __close?: () => void })
|
||||||
|
| null
|
||||||
if (menu?.dataset.open === "true") {
|
if (menu?.dataset.open === "true") {
|
||||||
document.body.style.overflow = ""
|
// 走 __close 而不是只清 overflow:inert 与焦点也要一起还原
|
||||||
|
menu.__close?.()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -72,12 +72,15 @@ export const favicon = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Web App Manifest:只在「添加到主屏幕 / 装成 App」时被浏览器取用。
|
* Web App Manifest:只在「添加到主屏幕 / 装成 App」时被浏览器取用。
|
||||||
* 文件不在 public/ 里,而是由 src/pages/favicon/[manifest].ts 按本配置生成,
|
* 文件不在 public/ 里,而是由 src/pages/favicon/site.webmanifest.ts 按本配置生成,
|
||||||
* 所以站名、图标路径不会和这里对不上。
|
* 所以站名、图标路径不会和这里对不上。
|
||||||
*/
|
*/
|
||||||
manifest: {
|
manifest: {
|
||||||
/** 生成的文件名。改这里,<link rel="manifest"> 和产物文件名一起变 */
|
/**
|
||||||
file: "site.webmanifest",
|
* manifest 的地址。由路由文件名决定(Astro 的规矩,和 robots.txt 一样),
|
||||||
|
* 改名字要连路由文件一起改 —— 对不上时构建会直接报错,不会默默产出到别的地址。
|
||||||
|
*/
|
||||||
|
url: "/favicon/site.webmanifest",
|
||||||
/** Android「添加到主屏幕」用的两档图标 */
|
/** Android「添加到主屏幕」用的两档图标 */
|
||||||
android192: "android-chrome-192x192.png",
|
android192: "android-chrome-192x192.png",
|
||||||
android512: "android-chrome-512x512.png",
|
android512: "android-chrome-512x512.png",
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ const resolvedTwitterDescription = twitterDescription || description
|
|||||||
<link rel="icon" href={faviconUrl(favicon.png32)} type="image/png" sizes="32x32" />
|
<link rel="icon" href={faviconUrl(favicon.png32)} type="image/png" sizes="32x32" />
|
||||||
<link rel="icon" href={faviconUrl(favicon.png16)} type="image/png" sizes="16x16" />
|
<link rel="icon" href={faviconUrl(favicon.png16)} type="image/png" sizes="16x16" />
|
||||||
<link rel="apple-touch-icon" href={faviconUrl(favicon.appleTouchIcon)} />
|
<link rel="apple-touch-icon" href={faviconUrl(favicon.appleTouchIcon)} />
|
||||||
<link rel="manifest" href={faviconUrl(favicon.manifest.file)} />
|
<link rel="manifest" href={favicon.manifest.url} />
|
||||||
<script is:inline define:vars={{ analytics }}>
|
<script is:inline define:vars={{ analytics }}>
|
||||||
;(() => {
|
;(() => {
|
||||||
// ClientRouter 切页时 head 里的内联脚本可能再次执行,用全局标记兜底
|
// ClientRouter 切页时 head 里的内联脚本可能再次执行,用全局标记兜底
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* 弹层的焦点管理(移动端菜单、图片灯箱共用)。
|
||||||
|
*
|
||||||
|
* 起因:`opacity: 0` 和 `pointer-events: none` **都不改变键盘可聚焦性**,所以"视觉上
|
||||||
|
* 隐藏"的弹层照样会吃掉 Tab 停靠点 —— 焦点环落在看不见的控件上(WCAG 2.4.7)。
|
||||||
|
* `aria-hidden` 也救不了:它只影响辅助技术,不影响 Tab 顺序。
|
||||||
|
*
|
||||||
|
* 这里统一用 `inert`:它对键盘和辅助技术同时生效;而且背景一旦 inert,
|
||||||
|
* Tab 自然只能在弹层内部循环,于是不用手写 focus trap。
|
||||||
|
*
|
||||||
|
* ⚠️ 容器**不一定**是 `<body>` 的直接子节点(移动端菜单就被包在页面的
|
||||||
|
* `<div class="min-h-screen">` 里),所以不能只遍历 `body.children` ——
|
||||||
|
* 那样会把容器的祖先一起 inert 掉,连弹层自己都点不动。
|
||||||
|
* 正确做法是沿容器的祖先链逐层处理:每一层里,把"不在通往容器的路径上"的
|
||||||
|
* 兄弟节点设为 inert。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FOCUSABLE_SELECTOR = [
|
||||||
|
"a[href]",
|
||||||
|
"button:not([disabled])",
|
||||||
|
"input:not([disabled])",
|
||||||
|
"select:not([disabled])",
|
||||||
|
"textarea:not([disabled])",
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
].join(", ")
|
||||||
|
|
||||||
|
export interface ModalFocusOptions {
|
||||||
|
/** 打开后聚焦到哪个元素。缺省用容器内第一个可聚焦元素 */
|
||||||
|
initialFocus?: HTMLElement | null
|
||||||
|
/** 关闭后把焦点还给谁。缺省还给打开前持有焦点的元素 */
|
||||||
|
restoreFocus?: HTMLElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把「容器之外的一切」设为 inert:从容器往上走到 `<body>`,每一层只处理旁支兄弟。
|
||||||
|
*
|
||||||
|
* 返回真正被改动的元素列表 —— 只收「原本没被 inert」的,这样还原时不会误清
|
||||||
|
* 别处(例如另一个弹层)设置的状态。
|
||||||
|
*/
|
||||||
|
const inertOutside = (container: HTMLElement): HTMLElement[] => {
|
||||||
|
const inerted: HTMLElement[] = []
|
||||||
|
|
||||||
|
let node: HTMLElement | null = container
|
||||||
|
while (node && node !== document.body) {
|
||||||
|
const parent: HTMLElement | null = node.parentElement
|
||||||
|
if (!parent) break
|
||||||
|
|
||||||
|
for (const sibling of Array.from(parent.children)) {
|
||||||
|
// 跳过"通往容器的那一支",以及非元素节点
|
||||||
|
if (sibling === node || !(sibling instanceof HTMLElement)) continue
|
||||||
|
if (sibling.inert) continue
|
||||||
|
sibling.inert = true
|
||||||
|
inerted.push(sibling)
|
||||||
|
}
|
||||||
|
|
||||||
|
node = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
return inerted
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开弹层:把容器之外的内容设为 inert,并把焦点移进弹层。
|
||||||
|
*
|
||||||
|
* 返回「关闭时清理」的函数 —— **必须调用**,否则背景会一直不可交互。
|
||||||
|
*/
|
||||||
|
export const openModalFocus = (container: HTMLElement, options: ModalFocusOptions = {}) => {
|
||||||
|
const inerted = inertOutside(container)
|
||||||
|
|
||||||
|
const restoreTarget =
|
||||||
|
options.restoreFocus ??
|
||||||
|
(document.activeElement instanceof HTMLElement ? document.activeElement : null)
|
||||||
|
|
||||||
|
const initial =
|
||||||
|
options.initialFocus ?? container.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ?? container
|
||||||
|
initial.focus({ preventScroll: true })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const el of inerted) el.inert = false
|
||||||
|
restoreTarget?.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||||
|
import SiteHeader from "@/components/site-header.astro"
|
||||||
|
import Footer from "@/components/footer.astro"
|
||||||
|
import { siteTitleSuffix } from "@/data/site"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 404 页面。
|
||||||
|
*
|
||||||
|
* 注意:光有这个文件不够 —— 静态产物是 dist/404.html,要让 Caddy 真正把它递给用户,
|
||||||
|
* 服务器端还需要(两个 Caddyfile 都要):
|
||||||
|
*
|
||||||
|
* handle_errors 404 {
|
||||||
|
* rewrite /404.html
|
||||||
|
* file_server
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* 否则 Caddy 的 file_server 只会返回一个 0 字节的空白 404(这正是加这个页面的原因)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const title = `页面不存在 | ${siteTitleSuffix}`
|
||||||
|
const description = "这个地址没有对应的页面。"
|
||||||
|
|
||||||
|
const entries = [
|
||||||
|
{ href: "/", label: "首页" },
|
||||||
|
{ href: "/timeline/", label: "时间线" },
|
||||||
|
{ href: "/thoughts/", label: "偶得" },
|
||||||
|
{ href: "/gallery/", label: "相册" },
|
||||||
|
{ href: "/bookshelf/", label: "书架" },
|
||||||
|
{ href: "/friends/", label: "友链" },
|
||||||
|
{ href: "/about/", label: "关于" },
|
||||||
|
]
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout {title} {description} robots="noindex" fontProfile="interface">
|
||||||
|
<div class="min-h-screen bg-background dark:bg-surface-subtle">
|
||||||
|
<SiteHeader />
|
||||||
|
<main id="main" class="mx-auto flex min-h-[70vh] max-w-2xl flex-col justify-center px-6 pt-24 pb-20">
|
||||||
|
<p class="mb-4 font-mono text-sm tracking-[0.25em] text-primary">404</p>
|
||||||
|
|
||||||
|
<h1 class="mb-4 flex items-center gap-3 text-2xl font-semibold text-foreground sm:text-3xl">
|
||||||
|
<span class="h-6 w-1 shrink-0 rounded-full bg-primary" />
|
||||||
|
这里没有页面
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="mb-2 leading-relaxed text-muted-foreground">
|
||||||
|
地址可能拼错了,或者这个页面已经被挪走。
|
||||||
|
</p>
|
||||||
|
<p class="mb-9 text-sm text-muted-foreground">
|
||||||
|
可以按 <kbd
|
||||||
|
class="inline-flex h-5 items-center rounded border border-border bg-muted/60 px-1.5 font-mono text-[11px]"
|
||||||
|
>⌘K</kbd> 搜一下,或者从下面挑一个入口。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<nav aria-label="站点入口" class="flex flex-wrap gap-2">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<a
|
||||||
|
href={entry.href}
|
||||||
|
class="rounded-lg border border-border px-3.5 py-2 text-sm text-muted-foreground transition-colors duration-200 hover:border-primary/40 hover:text-foreground"
|
||||||
|
>
|
||||||
|
{entry.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</BaseLayout>
|
||||||
@@ -32,7 +32,8 @@ const title =
|
|||||||
<div class="min-h-screen bg-surface-subtle">
|
<div class="min-h-screen bg-surface-subtle">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<main class="pt-24">
|
<main class="pt-24">
|
||||||
<section id="home-main" class="scroll-mt-24">
|
{/* 同 index.astro:手机端落点贴页眉下沿(列表开头是吸顶胶囊条),lg 以上保留呼吸位 */}
|
||||||
|
<section id="home-main" class="scroll-mt-[var(--header-h)] lg:scroll-mt-24">
|
||||||
<ArticleList
|
<ArticleList
|
||||||
client:visible
|
client:visible
|
||||||
articles={allArticles}
|
articles={allArticles}
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import fs from "node:fs"
|
|
||||||
import path from "node:path"
|
|
||||||
import type { APIRoute } from "astro"
|
|
||||||
import { favicon, faviconUrl, site } from "@/data/site"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Web App Manifest 由路由生成而非放在 public/,这样站名、图标路径都跟随
|
|
||||||
* src/data/site.ts —— 换 favicon 只需要改那一个文件,不会出现"manifest 没跟上"。
|
|
||||||
* 同样的思路见 src/pages/robots.txt.ts。
|
|
||||||
*
|
|
||||||
* 产物文件名取自 favicon.manifest.file,所以改名也在 site.ts 里做。
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** 本路由所在目录,必须和 favicon.dir 一致 —— 改了一个就得改名另一个 */
|
|
||||||
const ROUTE_DIR = "/favicon"
|
|
||||||
|
|
||||||
export function getStaticPaths() {
|
|
||||||
if (favicon.dir !== ROUTE_DIR) {
|
|
||||||
throw new Error(
|
|
||||||
`favicon.dir 是 "${favicon.dir}",但 manifest 路由在 src/pages${ROUTE_DIR}/ 下。` +
|
|
||||||
`请把该目录一并改名为 src/pages${favicon.dir}/,否则 manifest 会被生成到错误的路径上。`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// public/ 里的同名文件会在构建时胜出(Astro 只打一行 WARN 就跳过本路由),
|
|
||||||
// manifest 会静默停在旧内容。宁可构建失败,也不要默默把旧 manifest 推上线。
|
|
||||||
const shadow = path.join(process.cwd(), "public", favicon.dir, favicon.manifest.file)
|
|
||||||
if (fs.existsSync(shadow)) {
|
|
||||||
throw new Error(
|
|
||||||
`public${favicon.dir}/${favicon.manifest.file} 与本路由重名,构建时 public 里的那个会胜出,` +
|
|
||||||
`manifest 将不再跟随 site.ts。请删掉它 —— manifest 是自动生成的,不需要手写。`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return [{ params: { manifest: favicon.manifest.file } }]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GET: APIRoute = () => {
|
|
||||||
const { manifest } = favicon
|
|
||||||
|
|
||||||
const body = {
|
|
||||||
name: site.name,
|
|
||||||
description: site.description,
|
|
||||||
lang: site.lang,
|
|
||||||
start_url: "/",
|
|
||||||
display: "standalone",
|
|
||||||
background_color: manifest.backgroundColor,
|
|
||||||
theme_color: manifest.themeColor,
|
|
||||||
icons: [
|
|
||||||
{ src: faviconUrl(manifest.android192), sizes: "192x192", type: "image/png" },
|
|
||||||
{ src: faviconUrl(manifest.android512), sizes: "512x512", type: "image/png" },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(body, null, 2), {
|
|
||||||
headers: { "Content-Type": "application/manifest+json; charset=utf-8" },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import fs from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
import type { APIRoute } from "astro"
|
||||||
|
import { favicon, faviconUrl, site } from "@/data/site"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web App Manifest 由路由生成而非放在 public/,这样站名、图标路径都跟随
|
||||||
|
* src/data/site.ts —— 换 favicon 只需要改那一个文件,不会出现「manifest 没跟上」。
|
||||||
|
* 同样的思路见 src/pages/robots.txt.ts。
|
||||||
|
*
|
||||||
|
* ⚠️ 这里刻意用**固定文件名**,不用 [manifest].ts 这种动态路由:
|
||||||
|
* 本项目 trailingSlash 是 "always",而动态路由在 dev server 里只认带斜杠的
|
||||||
|
* /favicon/site.webmanifest/ —— 可 HTML 里写的是不带斜杠的地址,于是 dev 下必然 404,
|
||||||
|
* 和生产行为对不上(生产是 200)。固定文件名的端点和 robots.txt 一样,两边一致。
|
||||||
|
*
|
||||||
|
* 代价是地址由文件名决定,所以下面用断言把 site.ts 和文件名对上:
|
||||||
|
* 对不上就构建失败,而不是默默产出到别的地址。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 本路由所在目录与文件名,必须和 favicon.dir / favicon.manifest.url 一致 */
|
||||||
|
const ROUTE_DIR = "/favicon"
|
||||||
|
const ROUTE_FILE = "site.webmanifest"
|
||||||
|
const ROUTE_URL = `${ROUTE_DIR}/${ROUTE_FILE}`
|
||||||
|
|
||||||
|
if (favicon.dir !== ROUTE_DIR) {
|
||||||
|
throw new Error(
|
||||||
|
`favicon.dir 是 "${favicon.dir}",但 manifest 路由在 src/pages${ROUTE_DIR}/ 下。` +
|
||||||
|
`请把该目录一并改名为 src/pages${favicon.dir}/,否则 manifest 会被生成到错误的地址。`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (favicon.manifest.url !== ROUTE_URL) {
|
||||||
|
throw new Error(
|
||||||
|
`favicon.manifest.url 是 "${favicon.manifest.url}",但本路由的地址是 "${ROUTE_URL}"` +
|
||||||
|
`(Astro 端点的地址由文件名决定)。请把 src/pages${ROUTE_DIR}/${ROUTE_FILE}.ts ` +
|
||||||
|
`改名为 src/pages${favicon.manifest.url}.ts,或者把 site.ts 里的 url 改回来。`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// public/ 里的同名文件会在构建时胜出(Astro 只打一行 WARN 就跳过本路由),
|
||||||
|
// manifest 会静默停在旧内容。宁可构建失败,也不要默默把旧 manifest 推上线。
|
||||||
|
const shadow = path.join(process.cwd(), "public", ROUTE_DIR, ROUTE_FILE)
|
||||||
|
if (fs.existsSync(shadow)) {
|
||||||
|
throw new Error(
|
||||||
|
`public${ROUTE_URL} 与本路由重名,构建时 public 里的那个会胜出,` +
|
||||||
|
`manifest 将不再跟随 site.ts。请删掉它 —— manifest 是自动生成的,不需要手写。`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: APIRoute = () => {
|
||||||
|
const { manifest } = favicon
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
name: site.name,
|
||||||
|
description: site.description,
|
||||||
|
lang: site.lang,
|
||||||
|
start_url: "/",
|
||||||
|
display: "standalone",
|
||||||
|
background_color: manifest.backgroundColor,
|
||||||
|
theme_color: manifest.themeColor,
|
||||||
|
icons: [
|
||||||
|
{ src: faviconUrl(manifest.android192), sizes: "192x192", type: "image/png" },
|
||||||
|
{ src: faviconUrl(manifest.android512), sizes: "512x512", type: "image/png" },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(body, null, 2), {
|
||||||
|
headers: { "Content-Type": "application/manifest+json; charset=utf-8" },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -14,7 +14,10 @@ const albumId = Astro.params.album as string
|
|||||||
const album = galleryConfig.albums.find((a) => a.id === albumId)
|
const album = galleryConfig.albums.find((a) => a.id === albumId)
|
||||||
|
|
||||||
if (!album) {
|
if (!album) {
|
||||||
return Astro.redirect("/404/")
|
// 静态构建下这里其实不可达:getStaticPaths 只为 galleryConfig 里存在的相册生成页面,
|
||||||
|
// 访问不存在的相册不会走到这个组件(由服务器交给 404 页面)。留着是为了类型收窄。
|
||||||
|
// 目标指向构建产物里的 404 页面(src/pages/404.astro)—— 原来的 /404/ 是个不存在的路由。
|
||||||
|
return Astro.redirect("/404.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
const photos = scanAlbumPhotos(album.id)
|
const photos = scanAlbumPhotos(album.id)
|
||||||
@@ -38,7 +41,7 @@ const description = album.description ?? "相册"
|
|||||||
|
|
||||||
<a
|
<a
|
||||||
href="/gallery/"
|
href="/gallery/"
|
||||||
class="absolute left-4 top-4 rounded-lg bg-black/35 px-3 py-1.5 text-sm text-white/90 backdrop-blur-sm transition-colors hover:bg-black/55 hover:text-white"
|
class="absolute left-4 top-4 rounded-lg bg-black/50 px-3 py-1.5 text-sm text-white/90 transition-colors hover:bg-black/70 hover:text-white"
|
||||||
>
|
>
|
||||||
← 返回相册
|
← 返回相册
|
||||||
</a>
|
</a>
|
||||||
@@ -61,7 +64,7 @@ const description = album.description ?? "相册"
|
|||||||
{album.tags && album.tags.length > 0 && (
|
{album.tags && album.tags.length > 0 && (
|
||||||
<div class="mt-2.5 flex flex-wrap gap-1.5">
|
<div class="mt-2.5 flex flex-wrap gap-1.5">
|
||||||
{album.tags.map((tag) => (
|
{album.tags.map((tag) => (
|
||||||
<span class="rounded bg-white/20 px-2 py-0.5 text-xs text-white/90 backdrop-blur-sm">
|
<span class="rounded bg-black/45 px-2 py-0.5 text-xs text-white/90">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ const allTags = getAllAlbumTags(albums)
|
|||||||
|
|
||||||
<div class="absolute inset-0 bg-gradient-to-t from-black/75 via-black/25 to-transparent" />
|
<div class="absolute inset-0 bg-gradient-to-t from-black/75 via-black/25 to-transparent" />
|
||||||
|
|
||||||
<div class="absolute right-2 top-2 rounded-full bg-black/50 px-2 py-1 text-xs font-medium text-white backdrop-blur-sm">
|
<div class="absolute right-2 top-2 rounded-full bg-black/60 px-2 py-1 text-xs font-medium text-white">
|
||||||
{album.photoCount} 张
|
{album.photoCount} 张
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ const allTags = getAllAlbumTags(albums)
|
|||||||
{album.tags && album.tags.length > 0 && (
|
{album.tags && album.tags.length > 0 && (
|
||||||
<div class="mt-2 flex flex-wrap gap-1">
|
<div class="mt-2 flex flex-wrap gap-1">
|
||||||
{album.tags.slice(0, 4).map((tag) => (
|
{album.tags.slice(0, 4).map((tag) => (
|
||||||
<span class="rounded bg-white/20 px-1.5 py-0.5 text-[0.6rem] text-white/90 backdrop-blur-sm">
|
<span class="rounded bg-black/45 px-1.5 py-0.5 text-[0.6rem] text-white/90">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ const thoughtPreviews = thoughts.map((thought) => toThoughtPreview(thought)).sli
|
|||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<main>
|
<main>
|
||||||
<Hero client:idle />
|
<Hero client:idle />
|
||||||
<section id="home-main" class="scroll-mt-24">
|
{/*
|
||||||
|
手机端锚点落点要贴页眉下沿:这一屏列表的第一个元素是吸顶胶囊条
|
||||||
|
(article-list.tsx 的 filterBar),它 sticky 在 --header-h 处。scroll-mt 若再留出
|
||||||
|
额外的呼吸位,落点就会在页眉和胶囊条之间露出 Hero 的尾巴,看着像多出一段空白。
|
||||||
|
桌面端列表开头是标题、不是吸顶条,所以 lg 以上仍沿用原来的 96px。
|
||||||
|
*/}
|
||||||
|
<section id="home-main" class="scroll-mt-[var(--header-h)] lg:scroll-mt-24">
|
||||||
<ArticleList
|
<ArticleList
|
||||||
client:visible
|
client:visible
|
||||||
articles={allArticles}
|
articles={allArticles}
|
||||||
|
|||||||
+11
-34
@@ -86,23 +86,15 @@
|
|||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
/*
|
/*
|
||||||
* 纸纹底:内联 SVG 的 feTurbulence 分形噪声(无需图片文件)。
|
* 纸纹层与磨砂玻璃 token 已于 2026-09-20 移除。
|
||||||
* baseFrequency 越大颗粒越细,stitchTiles 保证平铺无接缝。
|
*
|
||||||
* feColorMatrix saturate=0 把彩色噪点去饱和,得到带随机 alpha 的灰颗粒,
|
* 原因:iPad(A14)上滚动最重的那篇文章(238 个 KaTeX 块 / 约 4150 个元素)时掉帧,
|
||||||
* 所以不需要 mix-blend-mode 也亮暗通用。
|
* 并出现未绘制的空白区域。两个"每帧都在算"的常驻开销是主因:
|
||||||
|
* 1. body::before —— 整屏 position:fixed 的噪声层压在正文之上,每帧混合一遍;
|
||||||
|
* 2. 固定页眉的 backdrop-blur-xl(24px)—— 每帧重算整幅宽度。
|
||||||
|
* 恢复办法(git 历史里有原实现):把 --paper-noise / --paper-noise-opacity /
|
||||||
|
* --glass-* 加回这里,body::before 规则加回下方,页眉 class 加回 backdrop-blur-xl。
|
||||||
*/
|
*/
|
||||||
--paper-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
|
||||||
--paper-noise-opacity: 0.2;
|
|
||||||
/*
|
|
||||||
* 磨砂玻璃 token —— 只给「浮在内容上方、面积不大」的元素用
|
|
||||||
* (移动端菜单、页眉、搜索面板…)。不要给文章卡片大面积用,
|
|
||||||
* 一屏十几个元素同时 backdrop-filter 会明显掉帧。
|
|
||||||
* 由 --card 派生,所以亮/暗色自动跟随。
|
|
||||||
*/
|
|
||||||
--glass-bg: color-mix(in oklch, var(--card) 72%, transparent);
|
|
||||||
--glass-border: color-mix(in oklch, var(--border) 75%, transparent);
|
|
||||||
--glass-blur: 14px;
|
|
||||||
--glass-saturate: 1.5;
|
|
||||||
--sidebar: oklch(0.968 0.012 84);
|
--sidebar: oklch(0.968 0.012 84);
|
||||||
--sidebar-foreground: oklch(0.235 0.013 68);
|
--sidebar-foreground: oklch(0.235 0.013 68);
|
||||||
--sidebar-primary: oklch(0.55 0.22 var(--primary-hue, 25));
|
--sidebar-primary: oklch(0.55 0.22 var(--primary-hue, 25));
|
||||||
@@ -185,8 +177,6 @@
|
|||||||
--math-color: oklch(0.90 0.008 250);
|
--math-color: oklch(0.90 0.008 250);
|
||||||
--math-display-color: oklch(0.94 0.008 250);
|
--math-display-color: oklch(0.94 0.008 250);
|
||||||
--math-display-shadow: 0 1px 0 oklch(0.08 0 0 / 0.18);
|
--math-display-shadow: 0 1px 0 oklch(0.08 0 0 / 0.18);
|
||||||
/* 暗色下颗粒要更明显一点才看得出来 */
|
|
||||||
--paper-noise-opacity: 0.22;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -294,23 +284,10 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 纸纹底:整屏一层极淡的颗粒。
|
* 纸纹层(body::before)已移除 —— 见上面 :root 里的说明。
|
||||||
* - position: fixed 让它不随内容滚动,像纸在屏幕后面而不是印在内容上
|
* 它是压在正文之上的一整屏 position:fixed 层,每帧都要混合一次,
|
||||||
* - z-index: 1 压在正文之上、页眉(z-50)/菜单(z-60) 之下;不透明度只有几个百分点,
|
* 在 iPad 上会让最重的文章页出现滚动掉帧与空白区域。
|
||||||
* 等效于全局加了一层胶片颗粒
|
|
||||||
* - pointer-events: none 必须留,否则整页点不动
|
|
||||||
* - 不支持 backdrop-filter 的老浏览器不受影响,它只是背景图
|
|
||||||
*/
|
*/
|
||||||
body::before {
|
|
||||||
content: "";
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1;
|
|
||||||
pointer-events: none;
|
|
||||||
background-image: var(--paper-noise);
|
|
||||||
background-repeat: repeat;
|
|
||||||
opacity: var(--paper-noise-opacity);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 横向滚动的胶囊条要藏掉滚动条,但保留键盘/触摸滚动能力 */
|
/* 横向滚动的胶囊条要藏掉滚动条,但保留键盘/触摸滚动能力 */
|
||||||
.no-scrollbar {
|
.no-scrollbar {
|
||||||
|
|||||||
Reference in New Issue
Block a user