-
- TenantCMS Demo
-
-
+
+
+
+
+
+
+ 书架
+
+ Library
+
+
+
+
+
+
diff --git a/src/data/books.ts b/src/data/books.ts
new file mode 100644
index 0000000..5b25006
--- /dev/null
+++ b/src/data/books.ts
@@ -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)
+}
diff --git a/src/index.css b/src/index.css
index b5dd061..f5c83d1 100644
--- a/src/index.css
+++ b/src/index.css
@@ -2,7 +2,80 @@
@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 {
margin: 0;
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;
+ }
}
diff --git a/src/pages/BookDetail.tsx b/src/pages/BookDetail.tsx
new file mode 100644
index 0000000..9a0322b
--- /dev/null
+++ b/src/pages/BookDetail.tsx
@@ -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 (
+
+
+
+
+
未找到这本书
+
它可能被借走了,或者书名写错了。
+
+
+ 回到书架 →
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+ 目录
+
+ {book.chapters.map((c, idx) => (
+ -
+
第 {idx + 1} 章
+ {c.title}
+
+ ))}
+
+
+
+
+ 试读
+
+ {book.chapters[0]?.content.split('\n\n').map((p, i) => (
+
{p}
+ ))}
+
+
+
+
+
+
+ )
+}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index cea6ae3..acb4fbd 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -1,113 +1,29 @@
-import React, { useEffect, useState } from 'react'
+import React from 'react'
import { Header } from '../components/Header'
import { Footer } from '../components/Footer'
-import { PostCard } from '../components/PostCard'
-import { PostCardSkeleton } from '../components/PostCardSkeleton'
-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,
- },
-})
+import { Bookshelf } from '../components/Bookshelf'
+import { BOOKS } from '../data/books'
export const Home: React.FC = () => {
- const [posts, setPosts] = useState
([])
- const [loading, setLoading] = useState(true)
- const [error, setError] = useState(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 (
-
+
-
-
- {error && (
-
- 错误: {error}
-
- )}
-
-
- {loading
- ? Array.from({ length: 6 }).map((_, i) =>
)
- : posts.map((post) => (
-
handlePostClick(post.slug)}
- />
- ))}
+
+
- {!loading && posts.length === 0 && !error && (
-
-
暂无文章
+
+
+
+
提示
+
+ 点击书脊选中,右上角会出现“翻开阅读”。每本书都有一个独立分页面:
+ /books/:slug。
+
- )}
+