feat(blog): BlogPage 接真实数据 + #post/<id> 路由集成

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cc 2026-06-27 16:54:41 +08:00
parent 6620be379b
commit e435a79950
2 changed files with 201 additions and 148 deletions

View File

@ -20,11 +20,16 @@ function App() {
) )
// 特殊文件夹入口:如果 hash 匹配一个注册文件夹,存文件夹名 // 特殊文件夹入口:如果 hash 匹配一个注册文件夹,存文件夹名
const [wantsFolder, setWantsFolder] = useState<string | null>(null) const [wantsFolder, setWantsFolder] = useState<string | null>(null)
// 文章路由:#post/<id> → 进 BlogPage 并首屏打开该文章
const [postId, setPostId] = useState<string | null>(() => {
const h = window.location.hash.replace(/^#/, '')
return h.startsWith('post/') ? h.slice('post/'.length) : null
})
// 路由是否还在异步解析中。非 #photo 的 hash 要等 checkFolder/checkGate 返回才知道 // 路由是否还在异步解析中。非 #photo 的 hash 要等 checkFolder/checkGate 返回才知道
// 去向;解析完成前先不渲染任何目标页,避免已登录时一闪 BlogPage 兜底再跳回。 // 去向;解析完成前先不渲染任何目标页,避免已登录时一闪 BlogPage 兜底再跳回。
const [resolving, setResolving] = useState(() => { const [resolving, setResolving] = useState(() => {
const h = window.location.hash.replace(/^#/, '') const h = window.location.hash.replace(/^#/, '')
return !!h && h !== PHOTO_HASH return !!h && h !== PHOTO_HASH && !h.startsWith('post/')
}) })
const signOut = auth.signOut const signOut = auth.signOut
@ -36,14 +41,16 @@ function App() {
const check = async () => { const check = async () => {
const hash = window.location.hash.replace(/^#/, '') const hash = window.location.hash.replace(/^#/, '')
const photo = hash === PHOTO_HASH const photo = hash === PHOTO_HASH
const post = hash.startsWith('post/') ? hash.slice('post/'.length) : null
if (alive) { if (alive) {
setWantsPhoto(photo) setWantsPhoto(photo)
setWantsFolder(null) setWantsFolder(null)
setPostId(post)
setShowLogin(false) setShowLogin(false)
// #photo 无 hash 都能同步定向,不需要异步解析 // #photo / #post/<id> / 无 hash 都能同步定向,不需要异步解析
setResolving(!!hash && !photo) setResolving(!!hash && !photo && !post)
} }
if (photo || !hash) return if (photo || post || !hash) return
// 先查是否是已注册的特殊文件夹 // 先查是否是已注册的特殊文件夹
const isFolder = await checkFolder(hash) const isFolder = await checkFolder(hash)
@ -104,9 +111,16 @@ function App() {
/> />
) )
} else { } else {
view = <BlogPage username={auth.username} onSignOut={goStart} /> view = (
<BlogPage
username={auth.username}
token={auth.token ?? ''}
initialPostId={postId}
onSignOut={goStart}
/>
)
} }
} else if (wantsPhoto || wantsFolder) { } else if (wantsPhoto || wantsFolder || postId) {
// 照片墙 / 特殊文件夹未登录:复用登录页,去掉顶部服务器负载栏 // 照片墙 / 特殊文件夹未登录:复用登录页,去掉顶部服务器负载栏
view = <LoginPage onSignIn={auth.signIn} onBack={goStart} hideStats /> view = <LoginPage onSignIn={auth.signIn} onBack={goStart} hideStats />
} else if (showLogin) { } else if (showLogin) {

View File

@ -1,61 +1,25 @@
import { useCallback, useEffect, useState } from 'react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useSystemStats } from '../hooks/useSystemStats' import { useSystemStats } from '../hooks/useSystemStats'
import { fadeUp, stagger } from './motion' import { fadeUp, stagger } from './motion'
import { AnnouncementBar } from './AnnouncementBar' import { AnnouncementBar } from './AnnouncementBar'
import { RainbowSpotlight } from './RainbowSpotlight' import { RainbowSpotlight } from './RainbowSpotlight'
import { PostArticle } from './PostArticle'
import { PostEditor } from './PostEditor'
import { listPosts, readingTime, excerpt, type Post } from '../api/posts'
interface BlogPageProps { interface BlogPageProps {
/** 当前登录用户名 */ /** 当前登录用户名 */
username: string username: string
/** 鉴权 token调 posts 接口用) */
token: string
/** 进入时若带 #post/<id>,首屏直接打开该文章 */
initialPostId: string | null
/** 退出登录 */ /** 退出登录 */
onSignOut: () => void onSignOut: () => void
} }
interface Post { // 两侧图片区:沿用原 import.meta.glob 索引 src/assets/sides/。
title: string
date: string
readingTime: string
excerpt: string
tag: string
}
// 示例文章数据(占位)。将来可换成从后端 /api/web/posts 拉取。
const POSTS: Post[] = [
{
title: 'An Interactive Guide to CSS Gradients',
date: 'Jun 2026',
readingTime: '12 min',
excerpt:
'从线性到锥形渐变,拆解每个参数如何影响最终画面,并顺手做一道流动的彩虹。',
tag: 'CSS',
},
{
title: '用 Rust + axum 写一个会呼吸的监控后端',
date: 'May 2026',
readingTime: '9 min',
excerpt:
'采样与请求解耦、RwLock 快照、CORS 只拦浏览器——把顶部栏那行系统状态讲透。',
tag: 'Rust',
},
{
title: 'framer-motion 里那些恰到好处的微交互',
date: 'Apr 2026',
readingTime: '7 min',
excerpt: '光标跟随、入场编排、spring 手感。动效不是炫技,是把注意力放对地方。',
tag: 'Motion',
},
{
title: '为什么我给只有 20 个用户的系统拒绝了数据库',
date: 'Mar 2026',
readingTime: '6 min',
excerpt: '一个进程内 HashMap 就够了。聊聊在小规模下,简单是怎样跑赢“正确架构”的。',
tag: 'Backend',
},
]
// 两侧图片区的图片:用 Vite 的 import.meta.glob 自动索引 src/assets/sides/ 下的所有图。
// 走 bundler import —— 自带内容哈希、构建期校验、可优化;丢新图进该文件夹即生效,无需改代码。
// 按文件名升序取用:第 1 张 → 左栏,第 2 张 → 右栏。详见 src/assets/sides/README.md。
const sideImages = Object.entries( const sideImages = Object.entries(
import.meta.glob<string>('../assets/sides/*.{jpg,jpeg,png,webp}', { import.meta.glob<string>('../assets/sides/*.{jpg,jpeg,png,webp}', {
eager: true, eager: true,
@ -69,18 +33,149 @@ const sideImages = Object.entries(
const SIDE_IMAGE_LEFT = sideImages[0] const SIDE_IMAGE_LEFT = sideImages[0]
const SIDE_IMAGE_RIGHT = sideImages[1] ?? sideImages[0] const SIDE_IMAGE_RIGHT = sideImages[1] ?? sideImages[0]
export function BlogPage({ username, onSignOut }: BlogPageProps) { function formatDate(secs: number): string {
// 顶部栏沿用系统状态彩蛋,保持与登录页同一风格。 return new Date(secs * 1000).toLocaleDateString('zh-CN', { year: 'numeric', month: 'short' })
}
export function BlogPage({ username, token, initialPostId, onSignOut }: BlogPageProps) {
const stats = useSystemStats() const stats = useSystemStats()
const [posts, setPosts] = useState<Post[]>([])
const [loading, setLoading] = useState(true)
// 编辑器浮层editing=true 时无视路由优先显示编辑器editingPost=null 为新建。
const [editing, setEditing] = useState(false)
const [editingPost, setEditingPost] = useState<Post | null>(null)
// 文章重挂载计数:保存编辑后 bump作为 PostArticle 的 key 强制重拉
// (否则 id 不变时其 useEffect 不会重新 fetch显示旧内容
const [articleNonce, setArticleNonce] = useState(0)
const refresh = useCallback(() => {
setLoading(true)
listPosts(token)
.then((r) => setPosts(r.items))
.catch(() => setPosts([]))
.finally(() => setLoading(false))
}, [token])
useEffect(() => {
refresh()
}, [refresh])
// 列表↔文章由地址栏驱动App 是唯一真相源):点文章只改 hash。
const openPost = (id: string) => {
window.location.hash = `post/${id}`
}
const backToList = () => {
setEditing(false)
if (window.location.hash) window.location.hash = ''
}
const startNew = () => {
setEditingPost(null)
setEditing(true)
}
const startEdit = (post: Post) => {
setEditingPost(post)
setEditing(true)
}
const onEditorSaved = () => {
// 编辑既有文章hash 仍是 #post/<id>关掉编辑器即回到该文章bump
// articleNonce 让 PostArticle 重挂载、重拉到最新内容。
// 新建hash 为空,关掉后回列表。两种都刷新列表数据。
setEditing(false)
setArticleNonce((n) => n + 1)
refresh()
}
const showHero = !editing && !initialPostId
let center
if (editing) {
center = (
<PostEditor
token={token}
post={editingPost ?? undefined}
onSaved={onEditorSaved}
onCancel={() => setEditing(false)}
/>
)
} else if (initialPostId) {
center = (
<PostArticle
key={`${initialPostId}-${articleNonce}`}
token={token}
id={initialPostId}
onBack={backToList}
onEdit={startEdit}
onDeleted={() => {
refresh()
backToList()
}}
/>
)
} else {
center = (
<section className="w-full px-6 py-14">
<div className="mb-10 flex items-center justify-between">
<h2 className="text-xs font-medium uppercase tracking-[0.3em] text-muted">
Latest writing
</h2>
<button
onClick={startNew}
className="rounded-full border border-line px-4 py-1.5 text-xs font-medium uppercase tracking-[0.16em] text-muted transition-colors hover:border-accent hover:text-accent"
>
</button>
</div>
{loading ? (
<p className="text-sm text-muted"></p>
) : posts.length === 0 ? (
<p className="text-sm text-muted"></p>
) : (
<motion.ul
variants={stagger(0.1, 0.08)}
initial="hidden"
animate="show"
className="flex flex-col"
>
{posts.map((post) => (
<motion.li key={post.id} variants={fadeUp}>
<button
onClick={() => openPost(post.id)}
className="group flex w-full flex-col gap-3 border-b border-line/50 py-7 text-left transition-colors sm:flex-row sm:items-baseline sm:gap-8"
>
<span
aria-hidden
className="rainbow-gradient hidden w-1 self-stretch rounded-full opacity-0 transition-opacity duration-300 group-hover:opacity-100 sm:block"
/>
<div className="w-28 shrink-0 font-mono text-xs uppercase tracking-wider text-muted">
<div>{formatDate(post.createdAt)}</div>
{post.tag && <div className="mt-1 text-accent/70">{post.tag}</div>}
</div>
<div className="flex-1">
<h3 className="rainbow-clip font-display text-xl font-medium text-text transition-colors duration-200 group-hover:text-transparent">
{post.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-muted">{excerpt(post.body)}</p>
<span className="mt-2 inline-block text-xs text-muted/70">
{readingTime(post.body)} read
</span>
</div>
</button>
</motion.li>
))}
</motion.ul>
)}
</section>
)
}
return ( return (
<div className="relative flex min-h-svh flex-col"> <div className="relative flex min-h-svh flex-col">
{/* 整页 Lunada 渐变极光背景,固定铺满视口、置于所有内容之下 */}
<div className="aurora" aria-hidden /> <div className="aurora" aria-hidden />
<AnnouncementBar text={stats.text} online={stats.online} /> <AnnouncementBar text={stats.text} online={stats.online} />
{/* 顶部彩虹栏:通栏,横跨左右图片区之上;底部一条流动彩虹线 */}
<header className="glass-sheet relative z-20 w-full"> <header className="glass-sheet relative z-20 w-full">
<div className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 py-5"> <div className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 py-5">
<span className="rainbow-text font-display text-lg font-semibold tracking-tight"> <span className="rainbow-text font-display text-lg font-semibold tracking-tight">
@ -101,110 +196,55 @@ export function BlogPage({ username, onSignOut }: BlogPageProps) {
<div className="rainbow-gradient h-1 w-full" aria-hidden /> <div className="rainbow-gradient h-1 w-full" aria-hidden />
</header> </header>
{/* Hero 通栏:彩虹光标动效 + 大字标题 + 灵感来源,占满一整行(在三栏之上) */} {showHero && (
<section className="glass-sheet relative z-10 w-full overflow-hidden py-20 sm:py-28"> <section className="glass-sheet relative z-10 w-full overflow-hidden py-20 sm:py-28">
<RainbowSpotlight /> <RainbowSpotlight />
<motion.div
<motion.div variants={stagger(0.15, 0.12)}
variants={stagger(0.15, 0.12)} initial="hidden"
initial="hidden" animate="show"
animate="show" className="relative z-10 mx-auto flex w-full max-w-3xl flex-col gap-6 px-6"
className="relative z-10 mx-auto flex w-full max-w-3xl flex-col gap-6 px-6"
>
<motion.span
variants={fadeUp}
className="text-xs font-medium uppercase tracking-[0.3em] text-accent"
> >
Personal blog <motion.span
</motion.span> variants={fadeUp}
className="text-xs font-medium uppercase tracking-[0.3em] text-accent"
<motion.h1
variants={fadeUp}
className="font-display text-5xl font-medium leading-[1.05] text-text sm:text-6xl lg:text-7xl"
>
Hello, I'm <span className="rainbow-text italic">cc</span>
<span className="cursor-bar ml-1 inline-block not-italic text-accent">_</span>
</motion.h1>
<motion.p
variants={fadeUp}
className="max-w-xl text-base leading-relaxed text-muted"
>
Rust
{' '}
<a
href="https://www.joshwcomeau.com/"
target="_blank"
rel="noreferrer"
className="text-accent underline-offset-4 hover:underline"
> >
Josh W. Comeau Personal blog
</a> </motion.span>
<motion.h1
</motion.p> variants={fadeUp}
</motion.div> className="font-display text-5xl font-medium leading-[1.05] text-text sm:text-6xl lg:text-7xl"
</section> >
Hello, I'm <span className="rainbow-text italic">{username}</span>
<span className="cursor-bar ml-1 inline-block not-italic text-accent">_</span>
</motion.h1>
<motion.p variants={fadeUp} className="max-w-xl text-base leading-relaxed text-muted">
Rust
{' '}
<a
href="https://www.joshwcomeau.com/"
target="_blank"
rel="noreferrer"
className="text-accent underline-offset-4 hover:underline"
>
Josh W. Comeau
</a>
</motion.p>
</motion.div>
</section>
)}
{/* · () ·
max-w-3xl flex-1 */}
<div className="relative flex flex-1 justify-center"> <div className="relative flex flex-1 justify-center">
{/* ()()
Lunada */}
<aside <aside
className="relative hidden flex-1 overflow-hidden bg-center bg-no-repeat lg:block" className="relative hidden flex-1 overflow-hidden bg-center bg-no-repeat lg:block"
style={{ backgroundImage: `url(${SIDE_IMAGE_LEFT})`, backgroundSize: 'auto 100%' }} style={{ backgroundImage: `url(${SIDE_IMAGE_LEFT})`, backgroundSize: 'auto 100%' }}
aria-hidden aria-hidden
/> />
{/* 中间内容纸(玻璃纸张) */}
<div className="glass-sheet relative flex w-full max-w-3xl flex-col"> <div className="glass-sheet relative flex w-full max-w-3xl flex-col">
<main className="flex-1"> <main className="flex-1">{center}</main>
{/* 文章列表 */}
<section className="w-full px-6 py-14">
<h2 className="mb-10 text-xs font-medium uppercase tracking-[0.3em] text-muted">
Latest writing
</h2>
<motion.ul
variants={stagger(0.1, 0.08)}
initial="hidden"
animate="show"
className="flex flex-col"
>
{POSTS.map((post) => (
<motion.li key={post.title} variants={fadeUp}>
<a
href="#"
className="group flex flex-col gap-3 border-b border-line/50 py-7 transition-colors sm:flex-row sm:items-baseline sm:gap-8"
>
{/* 左侧彩虹竖条hover 时出现 */}
<span
aria-hidden
className="rainbow-gradient hidden w-1 self-stretch rounded-full opacity-0 transition-opacity duration-300 group-hover:opacity-100 sm:block"
/>
<div className="w-28 shrink-0 font-mono text-xs uppercase tracking-wider text-muted">
<div>{post.date}</div>
<div className="mt-1 text-accent/70">{post.tag}</div>
</div>
<div className="flex-1">
<h3 className="rainbow-clip font-display text-xl font-medium text-text transition-colors duration-200 group-hover:text-transparent">
{post.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-muted">
{post.excerpt}
</p>
<span className="mt-2 inline-block text-xs text-muted/70">
{post.readingTime} read
</span>
</div>
</a>
</motion.li>
))}
</motion.ul>
</section>
</main>
{/* 页脚:一条流动彩虹装饰线 */}
<footer className="mt-auto"> <footer className="mt-auto">
<div className="rainbow-gradient h-1 w-full" /> <div className="rainbow-gradient h-1 w-full" />
<div className="flex w-full items-center justify-between px-6 py-8 text-xs text-muted"> <div className="flex w-full items-center justify-between px-6 py-8 text-xs text-muted">
@ -214,7 +254,6 @@ export function BlogPage({ username, onSignOut }: BlogPageProps) {
</footer> </footer>
</div> </div>
{/* 右侧图片区(与左侧同样的高度适配 + 居中) */}
<aside <aside
className="relative hidden flex-1 overflow-hidden bg-center bg-no-repeat lg:block" className="relative hidden flex-1 overflow-hidden bg-center bg-no-repeat lg:block"
style={{ backgroundImage: `url(${SIDE_IMAGE_RIGHT})`, backgroundSize: 'auto 100%' }} style={{ backgroundImage: `url(${SIDE_IMAGE_RIGHT})`, backgroundSize: 'auto 100%' }}