manual save(2026-01-27 15:58)

This commit is contained in:
SiteAgent Bot
2026-01-27 15:58:12 +08:00
parent a7a56ddd9c
commit 205fee0831
9 changed files with 677 additions and 117 deletions

View File

@@ -1,10 +1,14 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="zh-CN" dir="ltr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>react-template</title> <meta name="description" content="从书架挑一本书,翻开阅读。" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;600;700&family=Noto+Serif+SC:wght@500;700&display=swap" rel="stylesheet" />
<title>书架</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -3,12 +3,14 @@ import { Home } from './pages/Home'
import { PostDetail } from './pages/PostDetail' import { PostDetail } from './pages/PostDetail'
import { CategoriesPage } from './pages/Categories' import { CategoriesPage } from './pages/Categories'
import { CategoryDetail } from './pages/CategoryDetail' import { CategoryDetail } from './pages/CategoryDetail'
import { BookDetail } from './pages/BookDetail'
function App() { function App() {
return ( return (
<Router> <Router>
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/books/:slug" element={<BookDetail />} />
<Route path="/posts/:slug" element={<PostDetail />} /> <Route path="/posts/:slug" element={<PostDetail />} />
<Route path="/categories" element={<CategoriesPage />} /> <Route path="/categories" element={<CategoriesPage />} />
<Route path="/categories/:slug" element={<CategoryDetail />} /> <Route path="/categories/:slug" element={<CategoryDetail />} />

View File

@@ -0,0 +1,270 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import type { Book } from '../data/books'
type Props = {
books: Book[]
}
type Point = { x: number; y: number }
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n))
const usePrefersReducedMotion = () => {
const [reduced, setReduced] = useState(false)
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
const onChange = () => setReduced(mq.matches)
onChange()
mq.addEventListener?.('change', onChange)
return () => mq.removeEventListener?.('change', onChange)
}, [])
return reduced
}
const BookSpine: React.FC<{
book: Book
index: number
selected: boolean
onSelect: (event?: React.PointerEvent | React.MouseEvent) => void
}> = ({ book, index, selected, onSelect }) => {
const tilt = (index % 2 === 0 ? 1 : -1) * 0.9
const height = 148 + (index % 4) * 14
const width = 46 + (index % 3) * 6
return (
<div
role="button"
tabIndex={0}
aria-label={`选择《${book.title}`}
onClick={(e) => onSelect(e)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onSelect()
}}
className={
'relative select-none rounded-lg cursor-pointer outline-none transition-transform duration-300 ' +
'focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent focus-visible:ring-white/70'
}
style={{
width,
height,
transform: selected
? 'translateY(-10px) rotateZ(0deg)'
: `translateY(0px) rotateZ(${tilt}deg)`,
}}
>
<div
className={
'absolute inset-0 rounded-lg shadow-[0_18px_40px_rgba(0,0,0,0.45)] ' +
(selected ? 'ring-2 ring-white/60' : 'ring-1 ring-white/10')
}
style={{
background: `linear-gradient(180deg, ${book.spineColor} 0%, rgba(0,0,0,0.2) 100%)`,
}}
/>
<div
className="absolute inset-[1px] rounded-[7px]"
style={{
background:
'linear-gradient(90deg, rgba(255,255,255,0.10), rgba(255,255,255,0.02) 38%, rgba(0,0,0,0.18) 74%, rgba(255,255,255,0.04))',
}}
/>
<div
className="absolute left-2 right-2 top-3 rounded-sm"
style={{
height: 3,
background: `linear-gradient(90deg, transparent, ${book.accentColor}, transparent)`,
opacity: 0.9,
}}
/>
<div className="absolute inset-x-2 bottom-3">
<div
className="text-[11px] leading-tight text-white/90 font-semibold"
style={{
writingMode: 'vertical-rl',
textOrientation: 'mixed',
letterSpacing: '0.04em',
}}
>
{book.title}
</div>
<div
className="mt-2 text-[10px] text-white/70"
style={{ writingMode: 'vertical-rl', textOrientation: 'mixed' }}
>
{book.author}
</div>
</div>
<div
className="absolute -right-[10px] inset-y-2 rounded-r-md"
style={{
width: 10,
background:
'linear-gradient(180deg, rgba(255,255,255,0.16), rgba(0,0,0,0.2)), linear-gradient(90deg, rgba(0,0,0,0.4), transparent)',
opacity: selected ? 0.8 : 0.45,
}}
/>
</div>
)
}
export const Bookshelf: React.FC<Props> = ({ books }) => {
const reducedMotion = usePrefersReducedMotion()
const [selectedSlug, setSelectedSlug] = useState<string | null>(books[0]?.slug ?? null)
const [spark, setSpark] = useState<{ at: Point; key: number } | null>(null)
const sparkKeyRef = useRef(0)
const shelfRef = useRef<HTMLDivElement | null>(null)
const scrollerRef = useRef<HTMLDivElement | null>(null)
const selectedBook = useMemo(() => {
if (!selectedSlug) return undefined
return books.find((b) => b.slug === selectedSlug)
}, [books, selectedSlug])
useEffect(() => {
// Ensure the shelf starts from the left edge (some environments preserve scroll positions).
if (scrollerRef.current) scrollerRef.current.scrollLeft = 0
}, [])
const onSelect = (book: Book, event?: React.PointerEvent | React.MouseEvent) => {
setSelectedSlug(book.slug)
const rect = shelfRef.current?.getBoundingClientRect()
if (!rect) return
const ev = event as (React.PointerEvent | React.MouseEvent | undefined)
const clientX = ev?.clientX ?? rect.left + rect.width * 0.32
const clientY = ev?.clientY ?? rect.top + rect.height * 0.42
const x = clamp(clientX - rect.left, 10, rect.width - 10)
const y = clamp(clientY - rect.top, 10, rect.height - 10)
sparkKeyRef.current += 1
setSpark({ at: { x, y }, key: sparkKeyRef.current })
}
return (
<section aria-label="书架" className="w-full">
<div className="surface relative overflow-hidden rounded-2xl">
<div className="grain absolute inset-0 opacity-30" aria-hidden="true" />
<div className="relative px-5 py-5 sm:px-8 sm:py-7">
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-white/70 text-sm"></p>
<h2 className="font-display text-[28px] sm:text-[34px] leading-tight text-white">
</h2>
</div>
{selectedBook && (
<Link
to={`/books/${selectedBook.slug}`}
className="btn-ghost inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm text-white/90 transition-colors"
aria-label={`打开《${selectedBook.title}`}
>
<span className="font-semibold"></span>
<span aria-hidden="true"></span>
</Link>
)}
</div>
<div
ref={shelfRef}
className="relative mt-5 rounded-xl p-4 sm:p-5 shelf-wood"
style={{
boxShadow:
'inset 0 2px 0 rgba(255,255,255,0.08), inset 0 -10px 30px rgba(0,0,0,0.55), 0 40px 90px rgba(0,0,0,0.45)',
}}
>
<div
className="absolute inset-x-3 bottom-[10px] h-[10px] rounded-full opacity-70"
style={{
background:
'radial-gradient(80% 120% at 20% 10%, rgba(255,255,255,0.12), transparent 60%), radial-gradient(90% 130% at 90% 30%, rgba(0,0,0,0.65), transparent 55%)',
}}
aria-hidden="true"
/>
{!reducedMotion && spark && (
<div
key={spark.key}
className="pointer-events-none absolute"
style={{
left: spark.at.x,
top: spark.at.y,
width: 12,
height: 12,
transform: 'translate(-50%, -50%)',
}}
aria-hidden="true"
>
<div
className="absolute inset-0 rounded-full"
style={{
background: `radial-gradient(circle, ${selectedBook?.accentColor ?? '#fff'} 0%, transparent 70%)`,
animation: 'spark 820ms ease-out forwards',
}}
/>
</div>
)}
<div ref={scrollerRef} dir="ltr" className="flex items-end gap-3 overflow-x-auto pb-3 pt-2">
{books.map((book, i) => (
<div key={book.id} className="flex-none">
<BookSpine book={book} index={i} selected={book.slug === selectedSlug} onSelect={(e) => onSelect(book, e)} />
</div>
))}
</div>
</div>
{selectedBook && (
<div
className="mt-5 rounded-xl border border-white/12 bg-white/5 p-4 sm:p-5"
style={{
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.10)',
}}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-white/60 text-xs"></p>
<h3 className="font-display text-[22px] text-white leading-tight">
{selectedBook.title}
</h3>
{selectedBook.subtitle && (
<p className="text-white/70 text-sm mt-1">{selectedBook.subtitle}</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{selectedBook.tags.slice(0, 3).map((t) => (
<span
key={t}
className="rounded-full px-3 py-1 text-xs text-white/80"
style={{
background: 'rgba(255,255,255,0.08)',
border: '1px solid rgba(255,255,255,0.12)',
}}
>
{t}
</span>
))}
<span className="text-white/55 text-xs">{selectedBook.author} · {selectedBook.year}</span>
</div>
</div>
<p className="mt-3 text-white/80 leading-relaxed">{selectedBook.blurb}</p>
</div>
)}
</div>
</div>
<style>{`
@keyframes spark {
0% { opacity: 0; transform: scale(0.6); filter: blur(0px); }
15% { opacity: 0.95; transform: scale(1); }
100% { opacity: 0; transform: scale(7.5); filter: blur(2px); }
}
`}</style>
</section>
)
}

View File

@@ -2,12 +2,19 @@ import React from 'react'
export const Footer: React.FC = () => { export const Footer: React.FC = () => {
return ( return (
<footer className="bg-gray-50 border-t border-gray-200 py-8 mt-12"> <footer className="mt-10 pb-10">
<div className="container mx-auto px-4 text-center text-gray-600"> <div className="container mx-auto px-4">
<p>Powered by TenantCMS</p> <div className="surface rounded-2xl px-6 py-6 text-white">
<p className="text-sm mt-2"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
Using X-Tenant-Slug for multi-tenant authentication <div>
</p> <div className="font-display text-lg"></div>
<div className="text-white/70 text-sm mt-1"></div>
</div>
<div className="text-white/60 text-xs">
Demo UI · 2026-01-01
</div>
</div>
</div>
</div> </div>
</footer> </footer>
) )

View File

@@ -1,17 +1,60 @@
import React from 'react' import React from 'react'
import { Link, useLocation } from 'react-router-dom'
export const Header: React.FC = () => { export const Header: React.FC = () => {
const location = useLocation()
const isShelf = location.pathname === '/' || location.pathname.startsWith('/books')
return ( return (
<header className="bg-white border-b border-gray-200 sticky top-0 z-10"> <header className="sticky top-0 z-20">
<div className="container mx-auto px-4 py-4"> <div className="relative">
<div className="flex items-center justify-between"> <div
<h1 className="text-2xl font-bold text-gray-900"> className={
TenantCMS <span className="text-blue-600">Demo</span> (isShelf ? 'bg-transparent' : 'bg-white') +
</h1> ' border-b border-white/10'
<nav className="flex items-center gap-4"> }
<a href="/" className="text-gray-600 hover:text-gray-900"></a> style={{
<a href="/categories" className="text-gray-600 hover:text-gray-900"></a> backdropFilter: 'blur(10px)',
</nav> }}
>
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between gap-4">
<Link
to="/"
className={
(isShelf ? 'text-white' : 'text-gray-900') +
' text-xl sm:text-2xl font-bold tracking-tight'
}
aria-label="返回书架"
>
<span className="font-display"></span>
<span className={(isShelf ? 'text-white/70' : 'text-gray-500') + ' ml-2 text-sm font-semibold'}>
Library
</span>
</Link>
<nav className="flex items-center gap-3 sm:gap-4">
<Link
to="/"
className={
(isShelf ? 'text-white/80 hover:text-white' : 'text-gray-700 hover:text-gray-900') +
' text-sm font-semibold'
}
>
</Link>
<Link
to="/categories"
className={
(isShelf ? 'text-white/80 hover:text-white' : 'text-gray-700 hover:text-gray-900') +
' text-sm font-semibold'
}
>
</Link>
</nav>
</div>
</div>
</div> </div>
</div> </div>
</header> </header>

121
src/data/books.ts Normal file
View File

@@ -0,0 +1,121 @@
export type Book = {
id: string
slug: string
title: string
subtitle?: string
author: string
year: string
spineColor: string
accentColor: string
tags: string[]
blurb: string
chapters: { title: string; content: string }[]
}
export const BOOKS: Book[] = [
{
id: 'bk-ash-and-ink',
slug: 'ash-and-ink',
title: '灰与墨',
subtitle: '城市里的小型神话学',
author: '许闻',
year: '2026',
spineColor: '#0f172a',
accentColor: '#b42318',
tags: ['随笔', '城市', '神话'],
blurb:
'你以为是烟尘,其实是故事在落灰。挑一页翻开,看看你所在的街区究竟藏了多少种结局。',
chapters: [
{
title: '第一章:楼梯间的风',
content:
'夜里十二点半,楼梯间像一条把声音吞下去的河。你能听见鞋底在台阶上犹豫,能听见门锁轻轻叹气。城市的神话从不在广场上宣讲,它们更喜欢在狭窄处转身。\n\n我们决定用一盏小灯照亮每一次“差点发生”。',
},
{
title: '第二章:站牌上的第二层时间',
content:
'站牌的玻璃映出两个人:一个在等车,一个在等过去。你把手指贴上去,会发现冷不只是温度,它也是一种提醒:别把路走得太快。\n\n如果你愿意慢一点就能看见时间里那些极细的纹路。',
},
],
},
{
id: 'bk-field-notes',
slug: 'field-notes-of-light',
title: '光的田野笔记',
subtitle: '把日常折成可携带的地图',
author: '林相',
year: '2026',
spineColor: '#0b3d2e',
accentColor: '#0ea5a4',
tags: ['自然', '观察', '笔记'],
blurb:
'每一束光都有路径,只是你通常没空追踪。这里收录了九种光的日常用法:照见、隐藏、修补。',
chapters: [
{
title: '第一章:清晨的折射',
content:
'清晨的光不直走,它会拐弯,穿过杯口的水面,把厨房的墙涂成淡金色。你站在那片颜色里,会短暂相信:今天可以重新安排。\n\n把这一刻写进笔记就等于给自己留一条回到清醒的路。',
},
{
title: '第二章:影子的边界',
content:
'影子不是黑,它是光的证词。你看到的每一段阴影,都在说明某种存在的形状。\n\n试着在傍晚走慢一点影子会把你带到更准确的自我。',
},
],
},
{
id: 'bk-architecture-of-silence',
slug: 'architecture-of-silence',
title: '静默的建筑学',
subtitle: '如何在噪声里保留房间',
author: '周砚',
year: '2026',
spineColor: '#2f2a3a',
accentColor: '#f59e0b',
tags: ['设计', '心理', '空间'],
blurb:
'真正的安静不是没有声音,而是你能选择听见什么。把它从书架抽出来,给自己建一间房。',
chapters: [
{
title: '第一章:门槛与许可',
content:
'一间好房间首先要有门槛。门槛不是阻挡,是提醒:从这里开始,你拥有选择。\n\n在噪声里练习“允许”是建造静默的第一块砖。',
},
{
title: '第二章:窗的方向',
content:
'窗决定你每天看见的远方。你可以把窗开向树,也可以开向车流;你可以把窗开向自己,也可以开向他人的评价。\n\n改变方向不需要搬家。',
},
],
},
{
id: 'bk-micro-robots',
slug: 'micro-robots-for-everyday',
title: '日常微型机器人',
subtitle: '给生活加一点小型自动化',
author: '顾舟',
year: '2026',
spineColor: '#1f2937',
accentColor: '#60a5fa',
tags: ['技术', '创意', '生活'],
blurb:
'把“麻烦”拆成步骤,就能把它交给一个可靠的系统。这里的机器人不大,但够用。',
chapters: [
{
title: '第一章:把任务变小',
content:
'自动化从不从宏伟开始,它从一个“重复”开始。你写下第一条规则时,生活就多了一条可以走的捷径。\n\n从今天起把你最常做的三件小事列出来。',
},
{
title: '第二章:容错与温柔',
content:
'好的系统会原谅你:忘记、延迟、临时改变主意。容错不是降低标准,而是承认人类的形状。\n\n让机器人更温柔也是在让自己更轻松。',
},
],
},
]
export const getBookBySlug = (slug: string | undefined): Book | undefined => {
if (!slug) return undefined
return BOOKS.find((b) => b.slug === slug)
}

View File

@@ -2,7 +2,80 @@
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
:root {
--ink: #0b0f17;
--paper: #fbf7ef;
--paper-2: #fffdf8;
--wood-1: #2b1c12;
--wood-2: #3b2417;
--wood-3: #4a2d1d;
--accent: #b42318;
--accent-2: #0ea5a4;
}
html {
color: var(--ink);
background: radial-gradient(1200px 800px at 20% 10%, rgba(180, 35, 24, 0.12), transparent 55%),
radial-gradient(900px 700px at 85% 18%, rgba(14, 165, 164, 0.14), transparent 55%),
linear-gradient(180deg, #0f172a 0%, #101827 45%, #0b1220 100%);
overflow-x: hidden;
}
body { body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
font-family: "Noto Sans SC", ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,
"Apple Color Emoji", "Segoe UI Emoji";
direction: ltr;
text-align: left;
background: transparent;
overflow-x: hidden;
}
#root {
min-height: 100vh;
}
::selection {
background: rgba(180, 35, 24, 0.25);
}
.font-display {
font-family: "Noto Serif SC", ui-serif, Georgia, "Times New Roman", Times, serif;
}
.surface {
background: linear-gradient(180deg, rgba(255, 253, 248, 0.92), rgba(251, 247, 239, 0.88));
border: 1px solid rgba(255, 255, 255, 0.18);
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(10px);
}
.grain {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='240' height='240' filter='url(%23n)' opacity='.16'/%3E%3C/svg%3E");
background-size: 240px 240px;
mix-blend-mode: overlay;
pointer-events: none;
}
.shelf-wood {
background: linear-gradient(180deg, rgba(74, 45, 29, 0.95), rgba(43, 28, 18, 0.98));
}
.btn-ghost {
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.06);
}
.btn-ghost:hover {
background: rgba(255, 255, 255, 0.11);
}
@media (prefers-reduced-motion: reduce) {
* {
scroll-behavior: auto !important;
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
transition-duration: 1ms !important;
}
} }

124
src/pages/BookDetail.tsx Normal file
View File

@@ -0,0 +1,124 @@
import React, { useMemo } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Footer } from '../components/Footer'
import { Header } from '../components/Header'
import { getBookBySlug } from '../data/books'
export const BookDetail: React.FC = () => {
const { slug } = useParams<{ slug: string }>()
const book = useMemo(() => getBookBySlug(slug), [slug])
if (!book) {
return (
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-10">
<div className="surface rounded-2xl p-6 sm:p-8 text-white">
<h1 className="font-display text-2xl"></h1>
<p className="text-white/80 mt-2"></p>
<div className="mt-6">
<Link
to="/"
className="btn-ghost inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm text-white/90"
>
<span aria-hidden="true"></span>
</Link>
</div>
</div>
</main>
<Footer />
</div>
)
}
return (
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-10">
<article className="max-w-4xl mx-auto">
<header className="surface rounded-2xl p-6 sm:p-9 text-white relative overflow-hidden">
<div className="grain absolute inset-0 opacity-25" aria-hidden="true" />
<div className="relative">
<div className="flex items-center justify-between gap-4">
<Link
to="/"
className="btn-ghost inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm text-white/90"
>
<span aria-hidden="true"></span>
</Link>
<span
className="rounded-full px-3 py-1 text-xs text-white/85"
style={{
background: 'rgba(255,255,255,0.08)',
border: '1px solid rgba(255,255,255,0.12)',
}}
>
{book.author} · {book.year}
</span>
</div>
<h1 className="font-display text-[34px] sm:text-[44px] leading-tight mt-6">
{book.title}
</h1>
{book.subtitle && (
<p className="text-white/80 mt-3 text-lg">{book.subtitle}</p>
)}
<p className="text-white/85 mt-4 leading-relaxed">{book.blurb}</p>
<div className="mt-6 flex flex-wrap gap-2">
{book.tags.map((t) => (
<span
key={t}
className="rounded-full px-3 py-1 text-xs text-white/80"
style={{
background: 'rgba(255,255,255,0.07)',
border: '1px solid rgba(255,255,255,0.12)',
}}
>
{t}
</span>
))}
</div>
</div>
<div
className="absolute -right-24 -top-24 h-80 w-80 rounded-full blur-2xl opacity-60"
style={{
background: `radial-gradient(circle at 35% 35%, ${book.accentColor} 0%, transparent 62%)`,
}}
aria-hidden="true"
/>
</header>
<section className="mt-8 surface rounded-2xl p-6 sm:p-9 text-white">
<h2 className="font-display text-2xl"></h2>
<ol className="mt-4 grid gap-3">
{book.chapters.map((c, idx) => (
<li
key={c.title}
className="rounded-xl border border-white/12 bg-white/5 p-4"
>
<div className="text-white/70 text-xs"> {idx + 1} </div>
<div className="text-white text-lg mt-1">{c.title}</div>
</li>
))}
</ol>
</section>
<section className="mt-8 surface rounded-2xl p-6 sm:p-9 text-white">
<h2 className="font-display text-2xl"></h2>
<div className="mt-5 prose prose-invert max-w-none">
{book.chapters[0]?.content.split('\n\n').map((p, i) => (
<p key={i}>{p}</p>
))}
</div>
</section>
</article>
</main>
<Footer />
</div>
)
}

View File

@@ -1,113 +1,29 @@
import React, { useEffect, useState } from 'react' import React from 'react'
import { Header } from '../components/Header' import { Header } from '../components/Header'
import { Footer } from '../components/Footer' import { Footer } from '../components/Footer'
import { PostCard } from '../components/PostCard' import { Bookshelf } from '../components/Bookshelf'
import { PostCardSkeleton } from '../components/PostCardSkeleton' import { BOOKS } from '../data/books'
import { Posts } from '../clientsdk/sdk.gen'
import { createClient } from '../clientsdk/client'
import { customQuerySerializer } from '../clientsdk/querySerializer'
import { TENANT_SLUG, TENANT_API_KEY, API_URL } from '../config'
const client = createClient({
baseUrl: API_URL,
querySerializer: customQuerySerializer,
headers: {
'X-Tenant-Slug': TENANT_SLUG,
'X-API-Key': TENANT_API_KEY,
},
})
export const Home: React.FC = () => { export const Home: React.FC = () => {
const [posts, setPosts] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchPosts = async () => {
try {
setLoading(true)
setError(null)
const response = await Posts.listPosts({
client,
query: {
limit: 10,
sort: '-createdAt',
},
})
setPosts((response as any)?.data?.docs || [])
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
console.error('获取文章失败:', err)
} finally {
setLoading(false)
}
}
fetchPosts()
}, [])
const stripHtml = (html: string): string => {
const tmp = document.createElement('div')
tmp.innerHTML = html
return tmp.textContent || tmp.innerText || ''
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
const getCategoryTitle = (post: any): string | undefined => {
// categories is an array, get the first one
return post.categories?.[0]?.title
}
const handlePostClick = (slug: string) => {
window.location.href = `/posts/${slug}`
}
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen">
<Header /> <Header />
<main className="container mx-auto px-4 py-8"> <main className="container mx-auto px-4 py-8">
<section className="mb-12"> <div className="max-w-5xl mx-auto">
<h2 className="text-3xl font-bold text-gray-900 mb-2">📚 </h2> <Bookshelf books={BOOKS} />
<p className="text-gray-600"></p>
</section>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
<strong></strong> {error}
</div>
)}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{loading
? Array.from({ length: 6 }).map((_, i) => <PostCardSkeleton key={i} />)
: posts.map((post) => (
<PostCard
key={post.id}
title={post.title}
excerpt={stripHtml(post.content_html || post.content?.root?.children?.[0]?.children?.[0]?.text || post.title)}
category={getCategoryTitle(post)}
date={formatDate(post.createdAt)}
onClick={() => handlePostClick(post.slug)}
/>
))}
</div> </div>
{!loading && posts.length === 0 && !error && ( <section className="mt-10 max-w-5xl mx-auto">
<div className="text-center py-12"> <div className="surface relative rounded-2xl p-5 sm:p-7 text-white">
<p className="text-gray-500 text-lg"></p> <div className="grain absolute inset-0 opacity-20" aria-hidden="true" />
<h3 className="font-display text-2xl"></h3>
<p className="text-white/80 mt-2 leading-relaxed">
<span className="font-mono text-white/90">/books/:slug</span>
</p>
</div> </div>
)} </section>
</main> </main>
<Footer /> <Footer />