页脚「导航」栏 - 它是顶部导航的逐项复制(9 条名字和目标完全一样),手机上白占 312px - 已整栏删除;桌面端改用「最新文章」(最近 4 篇,按发布时间倒序, 不把置顶提前),把重复变成发现 - 手机端「最新文章」隐藏(上面就是文章列表,再列一遍没意义) - 不影响 SEO:顶部导航 hidden md:flex 和移动端菜单里的同一批链接 在每页 HTML 里都还在,爬虫照样抓得到 站点信息(新增) - 新增 src/lib/site-stats.ts:文章数/偶得数/分类数/标签数/总字数 在构建期算;「已运行 N 天」「最近更新」由页脚内联脚本按真实时间刷新, 避免站点久不重建时数字停在构建那天;服务端先渲染构建时兜底值,关 JS 不空 - 页脚加一条站点信息带:建站时间 / 已运行 / 文章 / 偶得 / 总字数 / 最近更新 桌面 6 列一行,手机 3 列两行 - 实测数值:2026-07-17 建站、已运行 60 天、30 篇文章、8 条偶得、 2.9 万字、最近更新 59 天前 「我的介绍」跳关于页 - 页脚 BRAND 栏整块包 <a href="/about/">,加 hover 反馈(头像描边转主色、 站名转主色、右上箭头位移)和常显的「关于我 →」提示(手机没有 hover, 不能只在 hover 才出现) - 侧栏作者卡片同样处理。注意这里**只包到签名那一段** —— 下面的社交图标 本身是 <a>,<a> 不能嵌套 <a>;页脚 BRAND 栏没有嵌套链接,所以可以整块包 - 两处都给 aria-label,避免读屏把「头像 alt + 站名 + 签名」念成链接名 工程改动 - footer.tsx -> footer.astro:React 组件不能在构建期 await 统计数据, 换成 Astro 组件后可以直接取;12 处引用同步改成默认导入 - README 补「页脚」一节,并修正组件路径 验证(headless Chrome 131,禁用缓存) - 桌面 1280:页脚 529px,三栏(我的介绍/最新文章/联系)+ 站点信息 + 底栏, 页脚内 nav 链接数 0,无横向溢出 - 手机 390:页脚 822px(原 957),最新文章 display:none,三栏只剩两栏 - 点页脚「我的介绍」-> /about/;点侧栏作者卡片 -> /about/,均正常 - 页面内 `a a` 嵌套数量:页脚 0、侧栏 0
237 lines
9.0 KiB
Plaintext
237 lines
9.0 KiB
Plaintext
---
|
||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||
import SiteHeader from "@/components/site-header.astro"
|
||
import Footer from "@/components/footer.astro"
|
||
import { siteTitleSuffix } from "@/data/site"
|
||
import { galleryConfig } from "@/data/gallery"
|
||
import { getAlbumCover, scanAlbumPhotos } from "@/lib/gallery"
|
||
|
||
export function getStaticPaths() {
|
||
return galleryConfig.albums.map((album) => ({ params: { album: album.id } }))
|
||
}
|
||
|
||
const albumId = Astro.params.album as string
|
||
const album = galleryConfig.albums.find((a) => a.id === albumId)
|
||
|
||
if (!album) {
|
||
return Astro.redirect("/404/")
|
||
}
|
||
|
||
const photos = scanAlbumPhotos(album.id)
|
||
const cover = getAlbumCover(album, photos)
|
||
const columnWidth = galleryConfig.columnWidth || 240
|
||
|
||
const title = `${album.name} | ${siteTitleSuffix}`
|
||
const description = album.description ?? "相册"
|
||
---
|
||
|
||
<BaseLayout {title} {description} fontProfile="interface">
|
||
<div class="min-h-screen bg-background dark:bg-surface-subtle">
|
||
<SiteHeader />
|
||
<main class="pt-20 pb-16">
|
||
{/* 封面横幅 */}
|
||
<section class="relative w-full overflow-hidden">
|
||
{cover ? (
|
||
<div class="relative h-[240px] w-full sm:h-[320px]">
|
||
<img src={cover} alt={album.name} class="h-full w-full object-cover" />
|
||
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/35 to-black/10" />
|
||
|
||
<a
|
||
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"
|
||
>
|
||
← 返回相册
|
||
</a>
|
||
|
||
<div class="absolute inset-x-0 bottom-0 p-6">
|
||
<div class="mx-auto max-w-6xl">
|
||
<h1 class="mb-2 text-2xl font-bold text-white drop-shadow-lg sm:text-3xl">
|
||
{album.name}
|
||
</h1>
|
||
{album.description && (
|
||
<p class="mb-2 max-w-2xl line-clamp-2 text-sm leading-relaxed text-white/75">
|
||
{album.description}
|
||
</p>
|
||
)}
|
||
<div class="flex flex-wrap items-center gap-4 text-sm text-white/80">
|
||
{album.date && <span>🗓 {album.date}</span>}
|
||
{album.location && <span>📍 {album.location}</span>}
|
||
<span>🖼 {photos.length} 张</span>
|
||
</div>
|
||
{album.tags && album.tags.length > 0 && (
|
||
<div class="mt-2.5 flex flex-wrap gap-1.5">
|
||
{album.tags.map((tag) => (
|
||
<span class="rounded bg-white/20 px-2 py-0.5 text-xs text-white/90 backdrop-blur-sm">
|
||
{tag}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div class="mx-auto max-w-6xl px-6 pt-4">
|
||
<a href="/gallery/" class="mb-3 inline-block text-sm text-primary hover:underline">
|
||
← 返回相册
|
||
</a>
|
||
<h1 class="text-2xl font-bold text-foreground sm:text-3xl">{album.name}</h1>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* 瀑布流 */}
|
||
<section class="mx-auto max-w-6xl px-6 pt-8">
|
||
{photos.length > 0 ? (
|
||
<div class="gallery-masonry" style={`--col-width: ${columnWidth}px;`}>
|
||
{photos.map((photo, index) => (
|
||
<button
|
||
type="button"
|
||
data-lightbox-src={photo}
|
||
data-lightbox-index={index}
|
||
aria-label={`查看第 ${index + 1} 张照片`}
|
||
class="gallery-photo mb-3 block w-full cursor-zoom-in overflow-hidden rounded-xl border border-border/50 bg-card p-0"
|
||
>
|
||
<img
|
||
src={photo}
|
||
alt=""
|
||
loading="lazy"
|
||
decoding="async"
|
||
class="h-auto w-full object-cover transition-transform duration-300 hover:scale-[1.03]"
|
||
/>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div class="py-20 text-center text-muted-foreground">
|
||
这个相册还没有照片。往 <code class="rounded bg-secondary px-1.5 py-0.5">public/gallery/{album.id}/</code> 里放图片即可。
|
||
</div>
|
||
)}
|
||
</section>
|
||
</main>
|
||
<Footer />
|
||
</div>
|
||
</BaseLayout>
|
||
|
||
<style is:global>
|
||
.gallery-masonry {
|
||
column-width: var(--col-width, 240px);
|
||
column-gap: 0.75rem;
|
||
}
|
||
.gallery-masonry .gallery-photo {
|
||
break-inside: avoid;
|
||
}
|
||
</style>
|
||
|
||
<script>
|
||
/**
|
||
* 无依赖灯箱。
|
||
* 用事件委托 + 每次打开时重新收集当前页的照片,因此 ClientRouter 切页后依然有效。
|
||
*/
|
||
const LB_ID = "vv-lightbox"
|
||
|
||
const ensureOverlay = (): HTMLElement => {
|
||
let overlay = document.getElementById(LB_ID)
|
||
if (overlay) return overlay
|
||
|
||
overlay = document.createElement("div")
|
||
overlay.id = LB_ID
|
||
overlay.setAttribute("role", "dialog")
|
||
overlay.setAttribute("aria-modal", "true")
|
||
overlay.className =
|
||
"fixed inset-0 z-[100] hidden items-center justify-center bg-black/90 backdrop-blur-sm"
|
||
overlay.innerHTML = `
|
||
<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>
|
||
<button type="button" data-lb-prev aria-label="上一张"
|
||
class="absolute left-3 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-2xl leading-none text-white/90 transition-colors hover:bg-white/20 sm:left-6">‹</button>
|
||
<button type="button" data-lb-next aria-label="下一张"
|
||
class="absolute right-3 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-2xl leading-none text-white/90 transition-colors hover:bg-white/20 sm:right-6">›</button>
|
||
<img data-lb-img src="" alt="" class="max-h-[88vh] max-w-[92vw] object-contain" />
|
||
<div data-lb-count
|
||
class="absolute bottom-5 left-1/2 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 font-mono text-xs text-white/80"></div>
|
||
`
|
||
document.body.appendChild(overlay)
|
||
return overlay
|
||
}
|
||
|
||
let sources: string[] = []
|
||
let current = 0
|
||
|
||
const render = (overlay: HTMLElement) => {
|
||
const img = overlay.querySelector<HTMLImageElement>("[data-lb-img]")
|
||
const counter = overlay.querySelector<HTMLElement>("[data-lb-count]")
|
||
if (img) img.src = sources[current] ?? ""
|
||
if (counter) counter.textContent = `${current + 1} / ${sources.length}`
|
||
const single = sources.length < 2
|
||
for (const sel of ["[data-lb-prev]", "[data-lb-next]"]) {
|
||
const el = overlay.querySelector<HTMLElement>(sel)
|
||
if (el) el.style.display = single ? "none" : ""
|
||
}
|
||
}
|
||
|
||
const openAt = (index: number) => {
|
||
const overlay = ensureOverlay()
|
||
current = index
|
||
render(overlay)
|
||
overlay.classList.remove("hidden")
|
||
overlay.classList.add("flex")
|
||
document.body.style.overflow = "hidden"
|
||
}
|
||
|
||
const close = () => {
|
||
const overlay = document.getElementById(LB_ID)
|
||
if (!overlay) return
|
||
overlay.classList.add("hidden")
|
||
overlay.classList.remove("flex")
|
||
document.body.style.overflow = ""
|
||
}
|
||
|
||
const step = (delta: number) => {
|
||
if (sources.length < 2) return
|
||
current = (current + delta + sources.length) % sources.length
|
||
const overlay = document.getElementById(LB_ID)
|
||
if (overlay) render(overlay)
|
||
}
|
||
|
||
if (!(window as unknown as { __vvLightboxBound?: boolean }).__vvLightboxBound) {
|
||
;(window as unknown as { __vvLightboxBound?: boolean }).__vvLightboxBound = true
|
||
|
||
document.addEventListener("click", (event) => {
|
||
const target = event.target as Element | null
|
||
|
||
const trigger = target?.closest<HTMLElement>("[data-lightbox-src]")
|
||
if (trigger) {
|
||
// 每次打开都重新收集当前页的照片
|
||
const all = Array.from(document.querySelectorAll<HTMLElement>("[data-lightbox-src]"))
|
||
sources = all.map((el) => el.dataset.lightboxSrc ?? "")
|
||
openAt(Number(trigger.dataset.lightboxIndex ?? all.indexOf(trigger)))
|
||
return
|
||
}
|
||
|
||
if (target?.closest("[data-lb-close]")) return close()
|
||
if (target?.closest("[data-lb-prev]")) return step(-1)
|
||
if (target?.closest("[data-lb-next]")) return step(1)
|
||
|
||
// 点背景关闭(点图片本身不关)
|
||
const overlay = document.getElementById(LB_ID)
|
||
if (overlay && target === overlay) close()
|
||
})
|
||
|
||
document.addEventListener("keydown", (event) => {
|
||
const overlay = document.getElementById(LB_ID)
|
||
if (!overlay || overlay.classList.contains("hidden")) return
|
||
if (event.key === "Escape") close()
|
||
if (event.key === "ArrowLeft") step(-1)
|
||
if (event.key === "ArrowRight") step(1)
|
||
})
|
||
|
||
// 切页时收拾干净,避免残留遮罩锁住滚动
|
||
document.addEventListener("astro:page-load", () => {
|
||
const overlay = document.getElementById(LB_ID)
|
||
if (overlay) overlay.remove()
|
||
document.body.style.overflow = ""
|
||
})
|
||
}
|
||
</script>
|