页脚改版:删掉重复的导航栏,桌面补站点信息;我的介绍可点去关于页
页脚「导航」栏 - 它是顶部导航的逐项复制(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
This commit is contained in:
@@ -168,7 +168,7 @@ epigraph: 题记 # 可选
|
|||||||
| 评论(twikoo) | `src/data/comments.ts` |
|
| 评论(twikoo) | `src/data/comments.ts` |
|
||||||
| 友链列表 | `src/data/friends.json` |
|
| 友链列表 | `src/data/friends.json` |
|
||||||
| 首页打字机文案 | `src/components/hero.tsx` 顶部的 `phrases` |
|
| 首页打字机文案 | `src/components/hero.tsx` 顶部的 `phrases` |
|
||||||
| 导航栏、页脚 | `src/components/site-header.astro`、`src/components/footer.tsx` |
|
| 导航栏、页脚 | `src/components/site-header.astro`、`src/components/footer.astro` |
|
||||||
| 配色与全局样式 | `src/styles/global.css` |
|
| 配色与全局样式 | `src/styles/global.css` |
|
||||||
| 代码块主题 | `astro.config.mjs` 里 `expressiveCode` 的 `themes` |
|
| 代码块主题 | `astro.config.mjs` 里 `expressiveCode` 的 `themes` |
|
||||||
| 免责声明模板 | `src/data/disclaimers.ts` |
|
| 免责声明模板 | `src/data/disclaimers.ts` |
|
||||||
@@ -233,6 +233,29 @@ prose-p:leading-[1.78] md:prose-p:leading-[1.85]
|
|||||||
|
|
||||||
`--header-h: 69px` 是固定页眉的实测高度,移动端菜单和这条胶囊条都靠它定位。**改页眉内边距或头像尺寸时记得同步改它**,否则吸附位置会错。
|
`--header-h: 69px` 是固定页眉的实测高度,移动端菜单和这条胶囊条都靠它定位。**改页眉内边距或头像尺寸时记得同步改它**,否则吸附位置会错。
|
||||||
|
|
||||||
|
### 页脚
|
||||||
|
|
||||||
|
`src/components/footer.astro`(原来是个 React 组件 `footer.tsx`,为了能在构建期 `await` 统计数据改成了 Astro 组件;引用方式随之从 `import { Footer } from "@/components/footer"` 变成 `import Footer from "@/components/footer.astro"`)。
|
||||||
|
|
||||||
|
结构:
|
||||||
|
|
||||||
|
| 区块 | 内容 | 显示范围 |
|
||||||
|
|---|---|---|
|
||||||
|
| 我的介绍 | 头像 + 站名 + 签名,**整块可点去 `/about/`** | 全部 |
|
||||||
|
| 最新文章 | 最近 4 篇(按发布时间倒序,不把置顶提前) | 桌面 |
|
||||||
|
| 联系 | 社交图标 + 订阅 RSS | 全部 |
|
||||||
|
| 站点信息 | 建站时间 / 已运行 / 文章 / 偶得 / 总字数 / 最近更新 | 全部(想只在桌面显示就给它加 `hidden md:grid`) |
|
||||||
|
| 底栏 | 版权 / 备案 / 主题署名 | 全部 |
|
||||||
|
|
||||||
|
**原来的「导航」栏已删除** —— 它是顶部导航的逐项复制(9 条名字和目标完全一样),手机上白占 312px。桌面端改用「最新文章」,把重复变成发现。不影响 SEO:顶部导航和移动端菜单里的链接在每页 HTML 里都还在。
|
||||||
|
|
||||||
|
两点需要注意:
|
||||||
|
|
||||||
|
- **「我的介绍」整块包 `<a>` 是合法的**,因为这一块里没有别的链接。但**侧栏的作者卡片不行** —— 它下面有社交图标(本身是 `<a>`),`<a>` 不能嵌套。所以那里只包到签名那一段。
|
||||||
|
- 整块链接要给 `aria-label`,否则读屏会把「头像 alt + 站名 + 签名」整段念成链接名。
|
||||||
|
|
||||||
|
统计数据在 `src/lib/site-stats.ts`:文章数/偶得数/分类数/标签数/总字数在构建时算,「已运行 N 天」和「最近更新」由页脚里的内联脚本按**真实时间**刷新(站点久不重建也不会停在构建那天),服务端会先渲染一份构建时兜底值,关 JS 也不空。加减统计项改 `footer.astro` 里的 `statCells` 数组即可。
|
||||||
|
|
||||||
### 磨砂玻璃
|
### 磨砂玻璃
|
||||||
|
|
||||||
`global.css` 里有四个 token:
|
`global.css` 里有四个 token:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
import SiteHeader from "@/components/site-header.astro"
|
import SiteHeader from "@/components/site-header.astro"
|
||||||
import { Footer } from "@/components/footer"
|
import Footer from "@/components/footer.astro"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import booksData from "../../public/data/books.json"
|
import booksData from "../../public/data/books.json"
|
||||||
import comicsData from "../../public/data/comics.json"
|
import comicsData from "../../public/data/comics.json"
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
---
|
||||||
|
import type { ComponentType, SVGProps } from "react"
|
||||||
|
import { ArrowRight, Github, Mail, Music, Rss } from "lucide-react"
|
||||||
|
import { QqIcon } from "@/components/icons/qq-icon"
|
||||||
|
import { profile, type ExternalProfileLink, type ExternalProfileLinkType } from "@/data/profile"
|
||||||
|
import { site } from "@/data/site"
|
||||||
|
import { formatWords, getSiteStats } from "@/lib/site-stats"
|
||||||
|
|
||||||
|
const iconMap: Record<ExternalProfileLinkType, ComponentType<SVGProps<SVGSVGElement>>> = {
|
||||||
|
qq: QqIcon,
|
||||||
|
music: Music,
|
||||||
|
github: Github,
|
||||||
|
email: Mail,
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await getSiteStats()
|
||||||
|
const contactLinks = profile.links.filter(
|
||||||
|
(link): link is ExternalProfileLink => link.type !== "wechat",
|
||||||
|
)
|
||||||
|
|
||||||
|
const statCells = [
|
||||||
|
{ label: "建站时间", value: stats.startDate, live: undefined },
|
||||||
|
{ label: "已运行", value: String(stats.runningDays), unit: "天", live: "running-days" },
|
||||||
|
{ label: "文章", value: String(stats.postCount), unit: "篇", live: undefined },
|
||||||
|
{ label: "偶得", value: String(stats.thoughtCount), unit: "条", live: undefined },
|
||||||
|
{ label: "总字数", value: formatWords(stats.totalWords), unit: "字", live: undefined },
|
||||||
|
{ label: "最近更新", value: stats.lastUpdatedText, live: "last-update" },
|
||||||
|
]
|
||||||
|
---
|
||||||
|
|
||||||
|
<footer class="border-t border-border/50 bg-background">
|
||||||
|
<div class="max-w-6xl mx-auto px-6 py-16">
|
||||||
|
<div class="grid gap-12 md:grid-cols-3">
|
||||||
|
{/*
|
||||||
|
我的介绍:整块可点去关于页(这里没有嵌套链接,所以整块包 <a> 是合法的)。
|
||||||
|
aria-label 是为了不让读屏把头像 alt + 站名 + 签名全念成链接名。
|
||||||
|
*/}
|
||||||
|
<a href="/about/" class="group block" aria-label={`关于 ${profile.name}`}>
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div class="w-8 h-8 rounded-full overflow-hidden border border-primary/20 bg-secondary transition-colors duration-200 group-hover:border-primary/60">
|
||||||
|
<img
|
||||||
|
src={profile.avatar}
|
||||||
|
alt={profile.name}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="text-foreground font-medium transition-colors duration-200 group-hover:text-primary">
|
||||||
|
{site.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground text-sm leading-relaxed">{profile.bio}</p>
|
||||||
|
<span class="mt-3 inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors duration-200 group-hover:text-primary">
|
||||||
|
关于我
|
||||||
|
<ArrowRight class="w-3 h-3 transition-transform duration-200 group-hover:translate-x-0.5" />
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
最新文章:手机端上面就是文章列表,再列一遍没意义,所以只在 md 以上显示。
|
||||||
|
页脚原来那栏「导航」是顶部导航的逐项复制(9 条完全一样),已删掉。
|
||||||
|
*/}
|
||||||
|
<div class="hidden md:block md:justify-self-center">
|
||||||
|
<h4 class="text-sm font-medium text-foreground mb-4 uppercase tracking-wide">最新文章</h4>
|
||||||
|
<ul class="flex flex-col gap-3">
|
||||||
|
{stats.latestPosts.map((post) => (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={post.href}
|
||||||
|
class="group flex items-baseline gap-3 text-sm text-muted-foreground hover:text-foreground transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<span class="tabular-nums text-xs text-muted-foreground/70 shrink-0">{post.date.slice(5)}</span>
|
||||||
|
<span class="line-clamp-1 transition-colors duration-200 group-hover:text-primary">{post.title}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:justify-self-end">
|
||||||
|
<h4 class="text-sm font-medium text-foreground mb-4 uppercase tracking-wide">联系</h4>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
{contactLinks.map((link) => {
|
||||||
|
const Icon = iconMap[link.type]
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="w-10 h-10 rounded-full border border-border flex items-center justify-center text-muted-foreground hover:text-foreground hover:border-primary/50 transition-all duration-200"
|
||||||
|
aria-label={link.name}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground text-sm mt-6">通过 RSS 订阅,获取最新文章更新。</p>
|
||||||
|
<a
|
||||||
|
href="/rss.xml"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:opacity-90 transition-opacity duration-200"
|
||||||
|
>
|
||||||
|
<Rss className="w-4 h-4" />
|
||||||
|
订阅 RSS
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 站点信息:桌面 6 列一行,手机 3 列两行 */}
|
||||||
|
<div class="mt-14 pt-8 border-t border-border/50 grid grid-cols-3 gap-x-4 gap-y-6 md:grid-cols-6">
|
||||||
|
{statCells.map((cell) => (
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground mb-1.5">{cell.label}</div>
|
||||||
|
<div class="text-foreground font-medium tabular-nums">
|
||||||
|
<span data-stat={cell.live}>{cell.value}</span>
|
||||||
|
{cell.unit && <span class="text-xs text-muted-foreground ml-0.5">{cell.unit}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom */}
|
||||||
|
<div class="mt-10 pt-8 border-t border-border/50 grid gap-4 md:grid-cols-3">
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
© {site.startDate.slice(0, 4)} {profile.name}. All rights reserved.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 md:justify-center">
|
||||||
|
<a
|
||||||
|
href="https://beian.miit.gov.cn/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="text-muted-foreground text-xs hover:text-foreground transition-colors duration-200"
|
||||||
|
>
|
||||||
|
陇ICP备2026007748号
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://beian.mps.gov.cn/#/query/webSearch?code=62070202000904"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="inline-flex items-center gap-1 text-muted-foreground text-xs hover:text-foreground transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<img src="/images/beian.png" alt="公安备案" class="h-3.5 w-auto shrink-0" />
|
||||||
|
甘公网安备62070202000904号
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://github.com/Lapis0x0/VermilionVoid"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="flex items-center gap-2 text-muted-foreground text-xs hover:text-foreground transition-colors duration-200 group md:justify-end md:pr-4"
|
||||||
|
>
|
||||||
|
<Github class="w-3.5 h-3.5" />
|
||||||
|
<span>博客主题:朱墨留白 | VermilionVoid</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script is:inline define:vars={{ siteStartDate: stats.startDate, lastUpdatedIso: stats.lastUpdatedIso }}>
|
||||||
|
// 运行天数和「最近更新」按真实时间算,避免站点久不重建时数字停在构建那天。
|
||||||
|
// 服务端已经渲染了一份兜底值,没 JS 也不会是空的。
|
||||||
|
;(() => {
|
||||||
|
const updateStats = () => {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const start = new Date(siteStartDate + "T00:00:00+08:00")
|
||||||
|
const days = Math.max(0, Math.floor((now.getTime() - start.getTime()) / 86400000))
|
||||||
|
document.querySelectorAll('[data-stat="running-days"]').forEach((el) => {
|
||||||
|
el.textContent = String(days)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!lastUpdatedIso) return
|
||||||
|
const last = new Date(lastUpdatedIso)
|
||||||
|
const diff = Math.max(0, Math.floor((now.getTime() - last.getTime()) / 86400000))
|
||||||
|
const text = diff === 0 ? "今天" : diff === 1 ? "昨天" : `${diff} 天前`
|
||||||
|
document.querySelectorAll('[data-stat="last-update"]').forEach((el) => {
|
||||||
|
el.textContent = text
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStats()
|
||||||
|
// ClientRouter 切页后页脚是新节点,要再刷一次
|
||||||
|
document.addEventListener("astro:page-load", updateStats)
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import type { ComponentType, SVGProps } from "react"
|
|
||||||
import { Github, Mail, Music, Rss } from "lucide-react"
|
|
||||||
import { profile, type ExternalProfileLink, type ExternalProfileLinkType } from "@/data/profile"
|
|
||||||
import { site } from "@/data/site"
|
|
||||||
import { QqIcon } from "@/components/icons/qq-icon"
|
|
||||||
|
|
||||||
const iconMap: Record<ExternalProfileLinkType, ComponentType<SVGProps<SVGSVGElement>>> = {
|
|
||||||
qq: QqIcon,
|
|
||||||
music: Music,
|
|
||||||
github: Github,
|
|
||||||
email: Mail,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Footer() {
|
|
||||||
return (
|
|
||||||
<footer className="border-t border-border/50 bg-background">
|
|
||||||
<div className="max-w-6xl mx-auto px-6 py-16">
|
|
||||||
<div className="grid gap-12 md:grid-cols-3">
|
|
||||||
{/* Brand */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<div className="w-8 h-8 rounded-full overflow-hidden border border-primary/20 bg-secondary">
|
|
||||||
<img
|
|
||||||
src={profile.avatar}
|
|
||||||
alt={profile.name}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-foreground font-medium">{site.name}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
||||||
{profile.bio}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<div className="md:flex md:justify-center">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-foreground mb-4 uppercase tracking-wide">导航</h4>
|
|
||||||
<nav className="flex flex-col gap-3">
|
|
||||||
{[
|
|
||||||
{ name: "首页", href: "/" },
|
|
||||||
{ name: "时间线", href: "/timeline/" },
|
|
||||||
{ name: "偶得", href: "/thoughts/" },
|
|
||||||
{ name: "相册", href: "/gallery/" },
|
|
||||||
{ name: "书架", href: "/bookshelf/" },
|
|
||||||
{ name: "专题", href: "/studies/" },
|
|
||||||
{ name: "友链", href: "/friends/" },
|
|
||||||
{ name: "关于", href: "/about/" },
|
|
||||||
{ name: "开往", href: "https://www.travellings.cn/go.html", external: true },
|
|
||||||
].map((item) => (
|
|
||||||
<a
|
|
||||||
key={item.name}
|
|
||||||
href={item.href}
|
|
||||||
target={item.external ? "_blank" : undefined}
|
|
||||||
rel={item.external ? "noreferrer" : undefined}
|
|
||||||
className="text-muted-foreground text-sm hover:text-foreground transition-colors duration-200"
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Connect */}
|
|
||||||
<div className="md:flex md:justify-end">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-foreground mb-4 uppercase tracking-wide">联系</h4>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{profile.links.filter((link): link is ExternalProfileLink => link.type !== "wechat").map((link) => {
|
|
||||||
const Icon = iconMap[link.type]
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={link.name}
|
|
||||||
href={link.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="w-10 h-10 rounded-full border border-border flex items-center justify-center text-muted-foreground hover:text-foreground hover:border-primary/50 transition-all duration-200"
|
|
||||||
aria-label={link.name}
|
|
||||||
>
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-sm mt-6">通过 RSS 订阅,获取最新文章更新。</p>
|
|
||||||
<a
|
|
||||||
href="/rss.xml"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:opacity-90 transition-opacity duration-200"
|
|
||||||
>
|
|
||||||
<Rss className="w-4 h-4" />
|
|
||||||
订阅 RSS
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom */}
|
|
||||||
<div className="mt-16 pt-8 border-t border-border/50 grid gap-4 md:grid-cols-3">
|
|
||||||
<p className="text-muted-foreground text-sm">© {site.startDate.slice(0, 4)} {profile.name}. All rights reserved.</p>
|
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 md:justify-center">
|
|
||||||
<a
|
|
||||||
href="https://beian.miit.gov.cn/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-muted-foreground text-xs hover:text-foreground transition-colors duration-200"
|
|
||||||
>
|
|
||||||
陇ICP备2026007748号
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://beian.mps.gov.cn/#/query/webSearch?code=62070202000904"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center gap-1 text-muted-foreground text-xs hover:text-foreground transition-colors duration-200"
|
|
||||||
>
|
|
||||||
<img src="/images/beian.png" alt="公安备案" className="h-3.5 w-auto shrink-0" />
|
|
||||||
甘公网安备62070202000904号
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href="https://github.com/Lapis0x0/VermilionVoid"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="flex items-center gap-2 text-muted-foreground text-xs hover:text-foreground transition-colors duration-200 group md:justify-end md:pr-4"
|
|
||||||
>
|
|
||||||
<Github className="w-3.5 h-3.5" />
|
|
||||||
<span>博客主题:朱墨留白 | VermilionVoid</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+25
-15
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, type ReactNode } from "react"
|
import { useState, type ReactNode } from "react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Calendar } from "lucide-react"
|
import { ArrowRight, Calendar } from "lucide-react"
|
||||||
import { profile } from "@/data/profile"
|
import { profile } from "@/data/profile"
|
||||||
import { ProfileContactLink } from "@/components/profile-contact-link"
|
import { ProfileContactLink } from "@/components/profile-contact-link"
|
||||||
|
|
||||||
@@ -39,23 +39,33 @@ export function Sidebar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-full lg:w-72 shrink-0 space-y-8">
|
<aside className="w-full lg:w-72 shrink-0 space-y-8">
|
||||||
{/* Author Card */}
|
{/* Author Card —— 头像/站名/签名整块可点,跳关于页。
|
||||||
|
注意只能包到这里:下面的社交图标本身是 <a>,<a> 不能嵌套 <a>。
|
||||||
|
aria-label 是为了不让读屏把头像 alt + 站名 + 签名整段念成链接名。 */}
|
||||||
<div className="bg-card border border-border/50 rounded-xl p-6 text-center">
|
<div className="bg-card border border-border/50 rounded-xl p-6 text-center">
|
||||||
<div className="w-24 h-24 mx-auto mb-4 rounded-full overflow-hidden border-2 border-primary/20">
|
<a href="/about/" className="group block" aria-label={`关于 ${profile.name}`}>
|
||||||
<img
|
<div className="w-24 h-24 mx-auto mb-4 rounded-full overflow-hidden border-2 border-primary/20 transition-colors duration-200 group-hover:border-primary/60">
|
||||||
src={profile.avatar}
|
<img
|
||||||
alt={profile.name}
|
src={profile.avatar}
|
||||||
loading="lazy"
|
alt={profile.name}
|
||||||
decoding="async"
|
loading="lazy"
|
||||||
className="w-full h-full object-cover"
|
decoding="async"
|
||||||
/>
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
</div>
|
/>
|
||||||
<h3 className="font-serif text-xl font-bold text-foreground mb-1">{profile.name}</h3>
|
</div>
|
||||||
<div className="w-8 h-0.5 bg-primary mx-auto mb-3" />
|
<h3 className="font-serif text-xl font-bold text-foreground mb-1 transition-colors duration-200 group-hover:text-primary">
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">{profile.bio}</p>
|
{profile.name}
|
||||||
|
</h3>
|
||||||
|
<div className="w-8 h-0.5 bg-primary mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">{profile.bio}</p>
|
||||||
|
<span className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors duration-200 group-hover:text-primary">
|
||||||
|
关于我
|
||||||
|
<ArrowRight className="w-3 h-3 transition-transform duration-200 group-hover:translate-x-0.5" />
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
{/* Social Links */}
|
{/* Social Links */}
|
||||||
<div className="flex items-center justify-center gap-3 mb-4">
|
<div className="flex items-center justify-center gap-3 mt-4 mb-4">
|
||||||
{profile.links.map((link) => {
|
{profile.links.map((link) => {
|
||||||
return (
|
return (
|
||||||
<ProfileContactLink
|
<ProfileContactLink
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { getCollection } from "astro:content"
|
||||||
|
import { site } from "@/data/site"
|
||||||
|
import { buildSidebarMeta, getSortedPosts, toPostMeta } from "./posts"
|
||||||
|
|
||||||
|
export type LatestPostLink = {
|
||||||
|
title: string
|
||||||
|
href: string
|
||||||
|
date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SiteStats = {
|
||||||
|
/** 建站日期,YYYY-MM-DD */
|
||||||
|
startDate: string
|
||||||
|
/** 已运行天数(构建时算的兜底值,页面加载后由内联脚本按真实时间刷新) */
|
||||||
|
runningDays: number
|
||||||
|
postCount: number
|
||||||
|
thoughtCount: number
|
||||||
|
categoryCount: number
|
||||||
|
tagCount: number
|
||||||
|
totalWords: number
|
||||||
|
/** 最后一篇的发布时间(ISO),用于前端算「最近更新」 */
|
||||||
|
lastUpdatedIso: string | null
|
||||||
|
/** 「最近更新」的构建时文案,作为无 JS 时的兜底 */
|
||||||
|
lastUpdatedText: string
|
||||||
|
/** 最近文章,按发布时间倒序(不把置顶提前) */
|
||||||
|
latestPosts: LatestPostLink[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把字数说成人话:12345 -> 1.2 万 */
|
||||||
|
export const formatWords = (n: number) => {
|
||||||
|
if (n >= 10000) return `${(n / 10000).toFixed(1)} 万`
|
||||||
|
return String(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||||
|
|
||||||
|
export async function getSiteStats(latestCount = 4): Promise<SiteStats> {
|
||||||
|
const posts = await getSortedPosts()
|
||||||
|
const metas = posts.map(toPostMeta)
|
||||||
|
const { categories, tags } = buildSidebarMeta(metas)
|
||||||
|
const thoughts = await getCollection("thoughts")
|
||||||
|
|
||||||
|
const totalWords = metas.reduce((sum, meta) => sum + (meta.wordCount || 0), 0)
|
||||||
|
|
||||||
|
// 分类里有一条 id 为 "all" 的合集项,统计时要去掉
|
||||||
|
const categoryCount = categories.filter((category) => category.id !== "all").length
|
||||||
|
|
||||||
|
const startDate = site.startDate
|
||||||
|
const runningDays = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor((Date.now() - startOfDay(new Date(`${startDate}T00:00:00+08:00`)).getTime()) / 86_400_000),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 「最近更新」看的是最后动过的时间,有 updated 就用它
|
||||||
|
const latestDate = posts.reduce<Date | null>((acc, post) => {
|
||||||
|
const d = new Date(post.data.updated ?? post.data.published)
|
||||||
|
return !acc || d > acc ? d : acc
|
||||||
|
}, null)
|
||||||
|
|
||||||
|
const latestPosts = [...posts]
|
||||||
|
.sort((a, b) => new Date(b.data.published).getTime() - new Date(a.data.published).getTime())
|
||||||
|
.slice(0, latestCount)
|
||||||
|
.map((post) => ({
|
||||||
|
title: post.data.title,
|
||||||
|
href: `/posts/${post.slug}/`,
|
||||||
|
date: new Date(post.data.published).toISOString().slice(0, 10),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const sinceUpdate = latestDate
|
||||||
|
? Math.max(0, Math.floor((Date.now() - latestDate.getTime()) / 86_400_000))
|
||||||
|
: null
|
||||||
|
const lastUpdatedText =
|
||||||
|
sinceUpdate === null ? "—" : sinceUpdate === 0 ? "今天" : sinceUpdate === 1 ? "昨天" : `${sinceUpdate} 天前`
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate,
|
||||||
|
runningDays,
|
||||||
|
postCount: posts.length,
|
||||||
|
thoughtCount: thoughts.length,
|
||||||
|
categoryCount,
|
||||||
|
tagCount: tags.length,
|
||||||
|
totalWords,
|
||||||
|
lastUpdatedIso: latestDate ? latestDate.toISOString() : null,
|
||||||
|
lastUpdatedText,
|
||||||
|
latestPosts,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { GetStaticPaths } from "astro"
|
import type { GetStaticPaths } from "astro"
|
||||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||||
import { ArticleList } from "@/components/article-list"
|
import { ArticleList } from "@/components/article-list"
|
||||||
import { Footer } from "@/components/footer"
|
import Footer from "@/components/footer.astro"
|
||||||
import SiteHeader from "@/components/site-header.astro"
|
import SiteHeader from "@/components/site-header.astro"
|
||||||
import { PAGE_SIZE } from "@/lib/constants"
|
import { PAGE_SIZE } from "@/lib/constants"
|
||||||
import { buildSidebarMeta, getSortedPosts, toPostMeta } from "@/lib/posts"
|
import { buildSidebarMeta, getSortedPosts, toPostMeta } from "@/lib/posts"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||||
import AboutPage from "@/components/pages/AboutPage"
|
import AboutPage from "@/components/pages/AboutPage"
|
||||||
import Comments from "@/components/Comments.astro"
|
import Comments from "@/components/Comments.astro"
|
||||||
import { Footer } from "@/components/footer"
|
import Footer from "@/components/footer.astro"
|
||||||
import SiteHeader from "@/components/site-header.astro"
|
import SiteHeader from "@/components/site-header.astro"
|
||||||
import { siteTitleSuffix } from "@/data/site"
|
import { siteTitleSuffix } from "@/data/site"
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||||
import FriendsPage from "@/components/pages/FriendsPage.astro"
|
import FriendsPage from "@/components/pages/FriendsPage.astro"
|
||||||
import Comments from "@/components/Comments.astro"
|
import Comments from "@/components/Comments.astro"
|
||||||
import { Footer } from "@/components/footer"
|
import Footer from "@/components/footer.astro"
|
||||||
import { siteTitleSuffix } from "@/data/site"
|
import { siteTitleSuffix } from "@/data/site"
|
||||||
|
|
||||||
const title = `友链 | ${siteTitleSuffix}`
|
const title = `友链 | ${siteTitleSuffix}`
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||||
import SiteHeader from "@/components/site-header.astro"
|
import SiteHeader from "@/components/site-header.astro"
|
||||||
import { Footer } from "@/components/footer"
|
import Footer from "@/components/footer.astro"
|
||||||
import { siteTitleSuffix } from "@/data/site"
|
import { siteTitleSuffix } from "@/data/site"
|
||||||
import { galleryConfig } from "@/data/gallery"
|
import { galleryConfig } from "@/data/gallery"
|
||||||
import { getAlbumCover, scanAlbumPhotos } from "@/lib/gallery"
|
import { getAlbumCover, scanAlbumPhotos } from "@/lib/gallery"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||||
import SiteHeader from "@/components/site-header.astro"
|
import SiteHeader from "@/components/site-header.astro"
|
||||||
import { Footer } from "@/components/footer"
|
import Footer from "@/components/footer.astro"
|
||||||
import { siteTitleSuffix } from "@/data/site"
|
import { siteTitleSuffix } from "@/data/site"
|
||||||
import { getAllAlbumTags, getAlbums } from "@/lib/gallery"
|
import { getAllAlbumTags, getAlbums } from "@/lib/gallery"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||||
import { ArticleList } from "@/components/article-list"
|
import { ArticleList } from "@/components/article-list"
|
||||||
import { Footer } from "@/components/footer"
|
import Footer from "@/components/footer.astro"
|
||||||
import SiteHeader from "@/components/site-header.astro"
|
import SiteHeader from "@/components/site-header.astro"
|
||||||
import { Hero } from "@/components/hero"
|
import { Hero } from "@/components/hero"
|
||||||
import { PAGE_SIZE } from "@/lib/constants"
|
import { PAGE_SIZE } from "@/lib/constants"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||||
import ArticleContent from "@/components/ArticleContent.astro"
|
import ArticleContent from "@/components/ArticleContent.astro"
|
||||||
import SiteHeader from "@/components/site-header.astro"
|
import SiteHeader from "@/components/site-header.astro"
|
||||||
import { Footer } from "@/components/footer"
|
import Footer from "@/components/footer.astro"
|
||||||
import { getPostsWithNav, type PostEntry } from "@/lib/posts"
|
import { getPostsWithNav, type PostEntry } from "@/lib/posts"
|
||||||
import Disclaimer from "@/components/Disclaimer.astro"
|
import Disclaimer from "@/components/Disclaimer.astro"
|
||||||
import PasswordProtection from "@/components/PasswordProtection.astro"
|
import PasswordProtection from "@/components/PasswordProtection.astro"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||||
import SiteHeader from "@/components/site-header.astro"
|
import SiteHeader from "@/components/site-header.astro"
|
||||||
import { Footer } from "@/components/footer"
|
import Footer from "@/components/footer.astro"
|
||||||
import { StudyDetailView } from "@/components/studies-detail"
|
import { StudyDetailView } from "@/components/studies-detail"
|
||||||
import { getAllStudies } from "@/lib/studies"
|
import { getAllStudies } from "@/lib/studies"
|
||||||
import { siteTitleSuffix } from "@/data/site"
|
import { siteTitleSuffix } from "@/data/site"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||||
import SiteHeader from "@/components/site-header.astro"
|
import SiteHeader from "@/components/site-header.astro"
|
||||||
import { Footer } from "@/components/footer"
|
import Footer from "@/components/footer.astro"
|
||||||
import { StudiesList } from "@/components/studies-list"
|
import { StudiesList } from "@/components/studies-list"
|
||||||
import { getAllStudies } from "@/lib/studies"
|
import { getAllStudies } from "@/lib/studies"
|
||||||
import { siteTitleSuffix } from "@/data/site"
|
import { siteTitleSuffix } from "@/data/site"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||||
import { ThoughtsView } from "@/components/thoughts-view"
|
import { ThoughtsView } from "@/components/thoughts-view"
|
||||||
import { Footer } from "@/components/footer"
|
import Footer from "@/components/footer.astro"
|
||||||
import SiteHeader from "@/components/site-header.astro"
|
import SiteHeader from "@/components/site-header.astro"
|
||||||
import { getSortedThoughts, toThoughtMeta } from "@/lib/thoughts"
|
import { getSortedThoughts, toThoughtMeta } from "@/lib/thoughts"
|
||||||
import { siteTitleSuffix } from "@/data/site"
|
import { siteTitleSuffix } from "@/data/site"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||||
import Timeline from "@/components/Timeline.astro"
|
import Timeline from "@/components/Timeline.astro"
|
||||||
import SiteHeader from "@/components/site-header.astro"
|
import SiteHeader from "@/components/site-header.astro"
|
||||||
import { Footer } from "@/components/footer"
|
import Footer from "@/components/footer.astro"
|
||||||
import { getSortedPosts } from "@/lib/posts"
|
import { getSortedPosts } from "@/lib/posts"
|
||||||
import { siteTitleSuffix } from "@/data/site"
|
import { siteTitleSuffix } from "@/data/site"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user