diff --git a/Client/src/App.tsx b/Client/src/App.tsx index b3c4fab..3445969 100644 --- a/Client/src/App.tsx +++ b/Client/src/App.tsx @@ -20,11 +20,16 @@ function App() { ) // 特殊文件夹入口:如果 hash 匹配一个注册文件夹,存文件夹名 const [wantsFolder, setWantsFolder] = useState(null) + // 文章路由:#post/ → 进 BlogPage 并首屏打开该文章 + const [postId, setPostId] = useState(() => { + 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/ / 无 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 = + view = ( + + ) } - } else if (wantsPhoto || wantsFolder) { + } else if (wantsPhoto || wantsFolder || postId) { // 照片墙 / 特殊文件夹未登录:复用登录页,去掉顶部服务器负载栏 view = } else if (showLogin) { diff --git a/Client/src/components/BlogPage.tsx b/Client/src/components/BlogPage.tsx index 0ad63e1..ab54329 100644 --- a/Client/src/components/BlogPage.tsx +++ b/Client/src/components/BlogPage.tsx @@ -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/,首屏直接打开该文章 */ + 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('../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([]) + const [loading, setLoading] = useState(true) + // 编辑器浮层:editing=true 时无视路由优先显示编辑器;editingPost=null 为新建。 + const [editing, setEditing] = useState(false) + const [editingPost, setEditingPost] = useState(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/,关掉编辑器即回到该文章;bump + // articleNonce 让 PostArticle 重挂载、重拉到最新内容。 + // 新建:hash 为空,关掉后回列表。两种都刷新列表数据。 + setEditing(false) + setArticleNonce((n) => n + 1) + refresh() + } + + const showHero = !editing && !initialPostId + + let center + if (editing) { + center = ( + setEditing(false)} + /> + ) + } else if (initialPostId) { + center = ( + { + refresh() + backToList() + }} + /> + ) + } else { + center = ( +
+
+

+ Latest writing +

+ +
+ + {loading ? ( +

加载中…

+ ) : posts.length === 0 ? ( +

还没有文章,点右上角「写文章」开始第一篇吧。

+ ) : ( + + {posts.map((post) => ( + + + + ))} + + )} +
+ ) + } + return (
- {/* 整页 Lunada 渐变极光背景,固定铺满视口、置于所有内容之下 */}
- {/* 顶部彩虹栏:通栏,横跨左右图片区之上;底部一条流动彩虹线 */}
@@ -101,110 +196,55 @@ export function BlogPage({ username, onSignOut }: BlogPageProps) {
- {/* Hero 通栏:彩虹光标动效 + 大字标题 + 灵感来源,占满一整行(在三栏之上) */} -
- - - - + + - Personal blog - - - - Hello, I'm cc - _ - - - - 我在这里写关于前端动效、Rust 后端和把事情做简单的笔记。把鼠标移到这片区域, - 会有一道彩虹跟着你——灵感来自{' '} - - Josh W. Comeau - - 。 - - -
+ Personal blog + + + Hello, I'm {username} + _ + + + 我在这里写关于前端动效、Rust 后端和把事情做简单的笔记。把鼠标移到这片区域, + 会有一道彩虹跟着你——灵感来自{' '} + + Josh W. Comeau + + 。 + + + + )} - {/* 三栏:左侧图片 · 中间内容(文章列表) · 右侧图片。 - 中间内容保持原比例(max-w-3xl),两侧 flex-1 图片区吃掉剩余留白(窄屏隐藏)。 */}
- {/* 左侧图片区:高度适配展示框、宽度按比例(不拉伸变形)、居中(水平溢出裁切)。 - 缺图时透出 Lunada 背景。 */}