feat(blog): BlogPage 接真实数据 + #post/<id> 路由集成
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6620be379b
commit
e435a79950
@ -20,11 +20,16 @@ function App() {
|
||||
)
|
||||
// 特殊文件夹入口:如果 hash 匹配一个注册文件夹,存文件夹名
|
||||
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 返回才知道
|
||||
// 去向;解析完成前先不渲染任何目标页,避免已登录时一闪 BlogPage 兜底再跳回。
|
||||
const [resolving, setResolving] = useState(() => {
|
||||
const h = window.location.hash.replace(/^#/, '')
|
||||
return !!h && h !== PHOTO_HASH
|
||||
return !!h && h !== PHOTO_HASH && !h.startsWith('post/')
|
||||
})
|
||||
const signOut = auth.signOut
|
||||
|
||||
@ -36,14 +41,16 @@ function App() {
|
||||
const check = async () => {
|
||||
const hash = window.location.hash.replace(/^#/, '')
|
||||
const photo = hash === PHOTO_HASH
|
||||
const post = hash.startsWith('post/') ? hash.slice('post/'.length) : null
|
||||
if (alive) {
|
||||
setWantsPhoto(photo)
|
||||
setWantsFolder(null)
|
||||
setPostId(post)
|
||||
setShowLogin(false)
|
||||
// #photo 与无 hash 都能同步定向,不需要异步解析
|
||||
setResolving(!!hash && !photo)
|
||||
// #photo / #post/<id> / 无 hash 都能同步定向,不需要异步解析
|
||||
setResolving(!!hash && !photo && !post)
|
||||
}
|
||||
if (photo || !hash) return
|
||||
if (photo || post || !hash) return
|
||||
|
||||
// 先查是否是已注册的特殊文件夹
|
||||
const isFolder = await checkFolder(hash)
|
||||
@ -104,9 +111,16 @@ function App() {
|
||||
/>
|
||||
)
|
||||
} 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 />
|
||||
} else if (showLogin) {
|
||||
|
||||
@ -1,61 +1,25 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useSystemStats } from '../hooks/useSystemStats'
|
||||
import { fadeUp, stagger } from './motion'
|
||||
import { AnnouncementBar } from './AnnouncementBar'
|
||||
import { RainbowSpotlight } from './RainbowSpotlight'
|
||||
import { PostArticle } from './PostArticle'
|
||||
import { PostEditor } from './PostEditor'
|
||||
import { listPosts, readingTime, excerpt, type Post } from '../api/posts'
|
||||
|
||||
interface BlogPageProps {
|
||||
/** 当前登录用户名 */
|
||||
username: string
|
||||
/** 鉴权 token(调 posts 接口用) */
|
||||
token: string
|
||||
/** 进入时若带 #post/<id>,首屏直接打开该文章 */
|
||||
initialPostId: string | null
|
||||
/** 退出登录 */
|
||||
onSignOut: () => void
|
||||
}
|
||||
|
||||
interface Post {
|
||||
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。
|
||||
// 两侧图片区:沿用原 import.meta.glob 索引 src/assets/sides/。
|
||||
const sideImages = Object.entries(
|
||||
import.meta.glob<string>('../assets/sides/*.{jpg,jpeg,png,webp}', {
|
||||
eager: true,
|
||||
@ -69,18 +33,149 @@ const sideImages = Object.entries(
|
||||
const SIDE_IMAGE_LEFT = 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 [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 (
|
||||
<div className="relative flex min-h-svh flex-col">
|
||||
{/* 整页 Lunada 渐变极光背景,固定铺满视口、置于所有内容之下 */}
|
||||
<div className="aurora" aria-hidden />
|
||||
|
||||
<AnnouncementBar text={stats.text} online={stats.online} />
|
||||
|
||||
{/* 顶部彩虹栏:通栏,横跨左右图片区之上;底部一条流动彩虹线 */}
|
||||
<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">
|
||||
<span className="rainbow-text font-display text-lg font-semibold tracking-tight">
|
||||
@ -101,10 +196,9 @@ export function BlogPage({ username, onSignOut }: BlogPageProps) {
|
||||
<div className="rainbow-gradient h-1 w-full" aria-hidden />
|
||||
</header>
|
||||
|
||||
{/* Hero 通栏:彩虹光标动效 + 大字标题 + 灵感来源,占满一整行(在三栏之上) */}
|
||||
{showHero && (
|
||||
<section className="glass-sheet relative z-10 w-full overflow-hidden py-20 sm:py-28">
|
||||
<RainbowSpotlight />
|
||||
|
||||
<motion.div
|
||||
variants={stagger(0.15, 0.12)}
|
||||
initial="hidden"
|
||||
@ -117,19 +211,14 @@ export function BlogPage({ username, onSignOut }: BlogPageProps) {
|
||||
>
|
||||
Personal blog
|
||||
</motion.span>
|
||||
|
||||
<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>
|
||||
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"
|
||||
>
|
||||
<motion.p variants={fadeUp} className="max-w-xl text-base leading-relaxed text-muted">
|
||||
我在这里写关于前端动效、Rust 后端和把事情做简单的笔记。把鼠标移到这片区域,
|
||||
会有一道彩虹跟着你——灵感来自{' '}
|
||||
<a
|
||||
@ -144,67 +233,18 @@ export function BlogPage({ username, onSignOut }: BlogPageProps) {
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 三栏:左侧图片 · 中间内容(文章列表) · 右侧图片。
|
||||
中间内容保持原比例(max-w-3xl),两侧 flex-1 图片区吃掉剩余留白(窄屏隐藏)。 */}
|
||||
<div className="relative flex flex-1 justify-center">
|
||||
{/* 左侧图片区:高度适配展示框、宽度按比例(不拉伸变形)、居中(水平溢出裁切)。
|
||||
缺图时透出 Lunada 背景。 */}
|
||||
<aside
|
||||
className="relative hidden flex-1 overflow-hidden bg-center bg-no-repeat lg:block"
|
||||
style={{ backgroundImage: `url(${SIDE_IMAGE_LEFT})`, backgroundSize: 'auto 100%' }}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* 中间内容纸(玻璃纸张) */}
|
||||
<div className="glass-sheet relative flex w-full max-w-3xl flex-col">
|
||||
<main className="flex-1">
|
||||
{/* 文章列表 */}
|
||||
<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>
|
||||
<main className="flex-1">{center}</main>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
@ -214,7 +254,6 @@ export function BlogPage({ username, onSignOut }: BlogPageProps) {
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
{/* 右侧图片区(与左侧同样的高度适配 + 居中) */}
|
||||
<aside
|
||||
className="relative hidden flex-1 overflow-hidden bg-center bg-no-repeat lg:block"
|
||||
style={{ backgroundImage: `url(${SIDE_IMAGE_RIGHT})`, backgroundSize: 'auto 100%' }}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user