diff --git a/README.md b/README.md index 58172d2..c1b4914 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,26 @@ dark atmosphere. Everything respects `prefers-reduced-motion`. Per the design bible there are **no counts anywhere** — quantity is expressed by form, never a number badge — and cinnabar, the colour of the human hand, is used sparingly. +## 千年书架 · 源氏物語 + +A second book is now fully implemented end to end, alongside 我的树: + +- **`/books`** — 千年书架, a small bookshelf listing every book on the site (道德經, + still a placeholder; 源氏物語, live). +- **`/books/genji`** — 源氏物語's book biography (「一本书的传记」): a real textual + history from 紫式部's 1008 diary entry, through the 青表纸本/河内本 manuscript + split, 本居宣长's 物哀 theory, the Waley/Seidensticker/Tyler English translations, + down to today's reader annotations (入木/青芽 rings). +- **`/books/genji/read`** — the reading page for 第一帖「桐壺」: the same signature + time-slider interaction as the design's 阅读页, dragged or stepped across 1000–2026, + opening a sentence into a "地层" (stratum) drawer of annotations that reveal + themselves as the slider passes their year — commentary (四辻善成, 本居宣长), + translations, and reader annotations, plus a hover-revealed 青表纸本/河内本 + wording-variant comparison. + +There is no server-side routing — `src/router.tsx` is a tiny client-side path +router (no dependency added) used only to switch between these pages. + ## Develop ```bash @@ -44,13 +64,18 @@ npm run preview # serve the production build | --- | --- | | `src/MyTree.tsx` | The 我的树 page component + its branch/annotation data | | `src/MyTree.css` | Page layout, the SVG tree, hover reveal, growth rings | +| `src/router.tsx` | Minimal client-side path router + `` | +| `src/Header.tsx` / `.css` | Shared header chrome for the bookshelf/book/reader pages | +| `src/Bookshelf.tsx` / `.css` | `/books` — 千年书架 | +| `src/BookGenji.tsx` / `.css` | `/books/genji` — 源氏物語's book biography | +| `src/GenjiReader.tsx` / `.css` | `/books/genji/read` — 源氏物語 reading page + time slider | | `src/theme.css` | Design tokens (墨字朱批 palette), day/night themes, keyframes | | `APP_NOTES.md` | Detailed notes on this page | | `design/` | The original Claude Design handoff bundle — chat transcript + all page prototypes. The source of truth for the pages not yet built. | ## Not yet built -`design/project/` holds prototypes for the rest of the product: 首页书架, 书籍详情页, -阅读页 (the design's stated priority — its "signature" screen with the time-slider), +`design/project/` also holds prototypes for 道德經's own book/reading pages, 落笔流程, 句子房间, 考据, 分享卡, 守树人. `我的树` links out to `/read` and `/inscribe` -as placeholders for those routes. +as placeholders for those routes; the bookshelf's 道德經 card is likewise still a +placeholder pending its own port. diff --git a/index.html b/index.html index 2b79873..18d6079 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,7 @@ diff --git a/src/BookGenji.css b/src/BookGenji.css new file mode 100644 index 0000000..fb8fa36 --- /dev/null +++ b/src/BookGenji.css @@ -0,0 +1,251 @@ +.genji-page { + min-height: 100vh; + font-family: 'Noto Sans SC', sans-serif; + color: var(--ink); +} + +.genji-hero { + max-width: 720px; + margin: 0 auto; + padding: 88px 44px 96px 44px; + position: relative; + box-sizing: border-box; +} + +.genji-hero-quote { + position: absolute; + right: 44px; + top: 80px; + writing-mode: vertical-rl; + font-family: 'Noto Serif JP', 'Noto Serif SC', serif; + font-size: 15px; + letter-spacing: 0.5em; + color: var(--ink-light); + opacity: 0.55; + line-height: 2; +} + +.genji-shelf { + font-size: 12px; + color: var(--ink-light); + letter-spacing: 0.3em; + margin-bottom: 32px; +} + +.genji-title { + font-family: 'Noto Serif SC', serif; + font-size: 52px; + font-weight: 400; + letter-spacing: 0.18em; + line-height: 1.3; +} + +.genji-meta { + margin-top: 20px; + font-family: Spectral, 'Noto Sans SC', serif; + font-feature-settings: 'onum'; + font-size: 13px; + color: var(--ink-light); + letter-spacing: 0.06em; +} + +.genji-lead { + margin-top: 40px; + font-family: 'Noto Serif SC', serif; + font-size: 21px; + letter-spacing: 0.05em; +} + +.genji-cta { + margin-top: 36px; + font-size: 13px; + letter-spacing: 0.14em; +} + +.genji-timeline { + max-width: 720px; + margin: 0 auto; + padding: 0 44px 60px 44px; + box-sizing: border-box; +} + +.genji-timeline-label { + font-size: 12px; + letter-spacing: 0.5em; + color: var(--ink-light); + margin-bottom: 56px; +} + +.genji-nodes { + position: relative; + padding-left: 140px; +} +.genji-nodes::before { + content: ''; + position: absolute; + left: 120px; + top: 8px; + bottom: 8px; + width: 1px; + background: var(--line); +} + +.genji-node { + position: relative; + margin-bottom: 76px; + transition: opacity 0.7s ease, transform 0.7s ease; +} + +.genji-node-year { + position: absolute; + left: -140px; + top: 3px; + width: 106px; + text-align: right; + font-family: Spectral, 'Noto Sans SC', serif; + font-feature-settings: 'onum'; + font-size: 13px; + letter-spacing: 0.04em; +} + +.genji-node-dot { + position: absolute; + left: -24px; + top: 6px; + width: 7px; + height: 7px; + border-radius: 50%; +} + +.genji-node-who { + font-size: 14px; + font-weight: 500; + letter-spacing: 0.04em; + margin-bottom: 10px; +} + +.genji-node-story { + font-family: 'Noto Serif SC', serif; + font-size: 14.5px; + line-height: 2.05; + letter-spacing: 0.02em; + color: var(--ink); +} + +.genji-node-anchor { + margin-top: 16px; + font-family: 'Noto Serif JP', 'Noto Serif SC', serif; + font-size: 15px; + line-height: 1.9; + color: var(--ink); + border-left: 2px solid var(--u-soft); + padding-left: 14px; +} + +.genji-special { + margin-top: 20px; + border: 0.5px solid var(--line); + border-radius: 8px; + padding: 20px 22px; +} + +.genji-special-label { + font-size: 11.5px; + letter-spacing: 0.24em; + color: var(--cinnabar); + margin-bottom: 16px; +} + +.genji-special-body { + font-family: 'Noto Serif SC', serif; + font-size: 13.5px; + line-height: 1.9; + color: var(--ink); +} + +.genji-rings { + margin-top: 20px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.genji-ring { + border: 0.5px solid var(--line); + border-radius: 8px; + padding: 16px 18px; +} + +.genji-ring-head { + display: flex; + align-items: baseline; + gap: 10px; + margin-bottom: 8px; + flex-wrap: wrap; +} + +.genji-ring-name { + font-size: 13px; + font-weight: 500; +} + +.genji-ring-badge { + font-size: 11px; + border: 0.5px solid; + border-radius: 4px; + padding: 1px 6px; + line-height: 1.5; +} + +.genji-ring-tag { + font-size: 11px; + letter-spacing: 0.1em; +} + +.genji-ring-date { + font-family: Spectral, serif; + font-feature-settings: 'onum'; + font-size: 12px; + color: var(--ink-light); + margin-left: auto; +} + +.genji-ring-text { + font-family: 'Noto Serif SC', serif; + font-size: 13.5px; + line-height: 1.9; + color: var(--ink); +} + +.genji-foot { + text-align: center; + padding: 0 44px 96px 44px; + font-size: 12px; + letter-spacing: 0.2em; + color: var(--ink-light); +} + +@media (max-width: 640px) { + .genji-hero, + .genji-timeline { + padding-left: 24px; + padding-right: 24px; + } + .genji-hero-quote { + display: none; + } + .genji-title { + font-size: 38px; + } + .genji-nodes { + padding-left: 96px; + } + .genji-nodes::before { + left: 80px; + } + .genji-node-year { + left: -96px; + width: 68px; + font-size: 11px; + } +} diff --git a/src/BookGenji.tsx b/src/BookGenji.tsx new file mode 100644 index 0000000..bbb7f96 --- /dev/null +++ b/src/BookGenji.tsx @@ -0,0 +1,273 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import Header from './Header' +import { Link } from './router' +import './BookGenji.css' + +type Ring = { + name: string + badge: string + tag: '入木' | '青芽' + date: string + text: string +} + +type Node = { + yl: string + who: string + story: string + anchor?: string + special?: boolean + cin?: boolean + today?: boolean +} + +const NODES: Node[] = [ + { + yl: '1008 年前后', + who: '紫式部执笔', + story: + '紫式部日记中第一次留下「源氏の物语」之名,写于她以女房身份侍读一条天皇中宫彰子期间。此后近千年,再没有人确切知道她动笔的那一天,也没有人见过她的亲笔手稿。', + anchor: 'いづれの御時にか、女御、更衣あまた候ひたまひける中に……', + }, + { + yl: '11 世纪', + who: '写本时代的散佚', + story: + '在印刷术传入日本之前,这本书只靠宫中女房手抄流传。紫式部的原稿从未留存,后世读到的,是抄本的抄本的抄本——每一次转抄,字句都会悄悄漂移一点。', + }, + { + yl: '1225 年前后', + who: '藤原定家「青表纸本」', + story: + '歌人藤原定家——《新古今和歌集》的编者之一——将当时纷杂的传抄本重新校订,写成后世通行的底本,因用青色封面装订得名「青表纸本」。此后近八百年,大多数刊本与译本都以此为源头。', + special: true, + cin: true, + }, + { + yl: '1236 年前后', + who: '源光行 · 源亲行「河内本」', + story: + '与定家同时,源光行、源亲行父子两代人另行校订出一部体系,称「河内本」。两个版本在字句、乃至个别章节顺序上都有出入——这本书从很早起,就不是唯一的一本。', + }, + { + yl: '1360 年代', + who: '四辻善成《河海抄》', + story: + '室町时代最重要的注释书之一。四百年后的读者已经读不懂平安朝的官阶、节令与和歌典故,《河海抄》逐句为它们作注——没有这部书,后世多数人将读不懂第一页。', + }, + { + yl: '1796 年', + who: '本居宣长《源氏物语玉の小栉》', + story: + '国学者本居宣长在此提出「物哀」(もののあはれ)说:这本书的核心不是儒家式的劝善惩恶,而是对人情世事的感发与共鸣。此后两百年,人们理解这部书的方式被他重新定义。', + }, + { + yl: '1925—1933', + who: 'Arthur Waley 英译', + story: + '第一部完整英译本,分六卷出版。译笔自由,略去了不少和歌与官职细节,却第一次让西方读者把它与普鲁斯特相提并论——一部十一世纪的日本宫廷小说,由此进入「世界文学」的谈论范围。', + }, + { + yl: '1976 年', + who: 'Edward Seidensticker 英译', + story: + '更贴近原文的新译本,此后数十年成为英语学界的通行译本。他在译序里写道:忠实与可读,几乎永远不能两全,他选择尽量不替紫式部做决定。', + }, + { + yl: '1980 年代', + who: '大和和纪《あさきゆめみし》', + story: + '漫画版《浅梦》连载十三年,成为此后数代日本人认识这部书的第一入口——很多人先在漫画里认识了光源氏,很多年后才回头去读平安朝的原文。', + }, + { + yl: '2001 年', + who: 'Royall Tyler 英译', + story: + '新译本刻意保留了原文的陌生感:书中人物本就没有姓名,只以官职、居所相称,泰勒没有像前人一样替他们「取名」,而是大量加注,把这种暧昧还给了读者。', + }, + { + yl: '今 · 2026', + who: '这本书仍在被写下', + story: '时间线的最后一站,是正在生长的年轮。最近入木与新生的批注:', + today: true, + cin: true, + }, +] + +const RINGS: Ring[] = [ + { + name: '苏未', + badge: '证', + tag: '入木', + date: '2024', + text: '母亲走后第二年重读桐壺卷,才明白「亡き人の面影身に添ひて」不是修辞——是那种走到哪儿都带着的重量,帝的哀伤原来一点都不夸张。', + }, + { + name: '林见山', + badge: '疑', + tag: '青芽', + date: '2025', + text: '源氏一生都在追一个替身的替身——藤壺像母亲,紫の上又像藤壺。这算是命运安排给他的诅咒,还是他自己一直在选择重复?', + }, + { + name: '陆知遥', + badge: '疑', + tag: '青芽', + date: '2026', + text: 'Waley 把桐壺更衣译成「the lady of the Paulownia Court」,可原文从没给过她一个名字。是我们比作者更想知道她是谁,还是没有名字才是紫式部真正想写的?', + }, +] + +export default function BookGenji() { + const [night, setNight] = useState(false) + const [seen, setSeen] = useState>({}) + const observerRef = useRef(null) + + const toggleNight = useCallback(() => { + setNight((prev) => { + const next = !prev + if (next) document.body.setAttribute('data-night', '1') + else document.body.removeAttribute('data-night') + return next + }) + }, []) + + useEffect(() => { + const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches + if (reduced || !('IntersectionObserver' in window)) { + setSeen(Object.fromEntries(NODES.map((_, i) => [i, true]))) + return + } + const io = new IntersectionObserver( + (entries) => { + setSeen((prev) => { + let changed = false + const next = { ...prev } + for (const entry of entries) { + if (entry.isIntersecting) { + const idx = Number(entry.target.getAttribute('data-node')) + if (!next[idx]) { + next[idx] = true + changed = true + observerRef.current?.unobserve(entry.target) + } + } + } + return changed ? next : prev + }) + }, + { threshold: 0.12 }, + ) + observerRef.current = io + const timers = [150, 1200].map((ms) => + window.setTimeout(() => { + document.querySelectorAll('[data-node]').forEach((el) => io.observe(el)) + }, ms), + ) + return () => { + io.disconnect() + timers.forEach((t) => window.clearTimeout(t)) + } + }, []) + + return ( +
+
+ +
+
いづれの御時にか
+
書架 · 日本古典
+
源氏物語
+
+ 约成书于公元 1008 年前后 · 五十四帖,近百万言 · 已生长约 1,020 年 · 92 层年轮 +
+
五十四帖,写尽人心与无常。
+
+ + 进入第一帖「桐壺」,亲自读 → + +
+
+ +
+
一本书的传记
+
+ {NODES.map((n, i) => ( +
+
+ {n.yl} +
+
+
{n.who}
+
{n.story}
+ {n.anchor &&
{n.anchor}
} + + {n.special && ( +
+
经文自此有了两条源流
+
+ 定家的「青表纸本」与源氏父子的「河内本」在部分字句、乃至个别章节的先后顺序上互有出入——现存所有刊本与译本,几乎都要先在这两条源流之间做出取舍。 +
+
+ + 在阅读页看这一支源流如何进入通行本 → + +
+
+ )} + + {n.today && ( +
+ {RINGS.map((r, ri) => ( +
+
+ + {r.name} + + + {r.badge} + + + {r.tag} + + {r.date} +
+
{r.text}
+
+ ))} +
+ + 进入,亲自落笔 → + +
+
+ )} +
+ ))} +
+
+ +
越老的书,年轮越厚。
+
+ ) +} diff --git a/src/Bookshelf.css b/src/Bookshelf.css new file mode 100644 index 0000000..b41346a --- /dev/null +++ b/src/Bookshelf.css @@ -0,0 +1,104 @@ +.shelf-page { + min-height: 100vh; + font-family: 'Noto Sans SC', sans-serif; + color: var(--ink); +} + +.shelf-stage { + max-width: 880px; + margin: 0 auto; + padding: 64px 44px 120px 44px; +} + +.shelf-lead { + max-width: 520px; +} + +.shelf-title { + font-family: 'Noto Serif SC', serif; + font-size: 27px; + letter-spacing: 0.08em; + line-height: 1.8; +} + +.shelf-sub { + margin-top: 14px; + font-size: 13px; + line-height: 1.9; + color: var(--ink-light); + letter-spacing: 0.04em; +} + +.shelf-row { + display: flex; + flex-wrap: wrap; + gap: 20px; + margin-top: 72px; +} + +.shelf-card { + flex: 1 1 240px; + min-width: 220px; + border: 1px solid var(--ink); + border-radius: 2px; + padding: 24px 22px; + text-decoration: none; + color: var(--ink); + transition: transform 0.3s ease, background-color 0.5s ease; +} +.shelf-card:hover { + transform: translateY(-4px); +} + +.shelf-card-tag { + font-size: 11px; + letter-spacing: 0.2em; + color: var(--ink-light); +} + +.shelf-card-title { + margin-top: 14px; + font-family: 'Noto Serif SC', serif; + font-size: 26px; + letter-spacing: 0.1em; +} + +.shelf-card-line { + margin-top: 14px; + font-family: 'Noto Serif SC', serif; + font-size: 14px; + line-height: 1.8; + color: var(--ink); +} + +.shelf-card-meta { + margin-top: 10px; + font-family: Spectral, 'Noto Sans SC', serif; + font-feature-settings: 'onum'; + font-size: 12px; + color: var(--ink-light); +} + +.shelf-card-cta { + margin-top: 18px; + font-size: 12px; + letter-spacing: 0.12em; + color: var(--cinnabar); +} +.shelf-card-cta.is-dim { + color: var(--verdigris); +} + +.shelf-foot { + margin-top: 88px; + text-align: center; + font-size: 12px; + letter-spacing: 0.2em; + color: var(--ink-light); +} + +@media (max-width: 520px) { + .shelf-stage { + padding: 48px 24px 96px 24px; + } +} diff --git a/src/Bookshelf.tsx b/src/Bookshelf.tsx new file mode 100644 index 0000000..c8f13e3 --- /dev/null +++ b/src/Bookshelf.tsx @@ -0,0 +1,81 @@ +import { useCallback, useState } from 'react' +import Header from './Header' +import { Link } from './router' +import './Bookshelf.css' + +type ShelfBook = { + id: string + title: string + shelf: string + line: string + meta: string + href: string + live: boolean +} + +const BOOKS: ShelfBook[] = [ + { + id: 'ddj', + title: '道德經', + shelf: '書架 · 道家', + line: '五千字,两千年读不尽。', + meta: '已生长约 2,400 年 · 137 层年轮', + href: '/read', + live: false, + }, + { + id: 'genji', + title: '源氏物語', + shelf: '書架 · 日本古典', + line: '五十四帖,写尽人心与无常。', + meta: '已生长约 1,020 年 · 92 层年轮', + href: '/books/genji', + live: true, + }, +] + +export default function Bookshelf() { + const [night, setNight] = useState(false) + + const toggleNight = useCallback(() => { + setNight((prev) => { + const next = !prev + if (next) document.body.setAttribute('data-night', '1') + else document.body.removeAttribute('data-night') + return next + }) + }, []) + + return ( +
+
+ +
+
+
一座还在生长的碑林。
+
+ AI 长出土壤,人在其上署名落笔,时间让字入木。 +
+ 免登录,全站可读。 +
+
+ +
+ {BOOKS.map((b) => ( + +
{b.shelf}
+
{b.title}
+
{b.line}
+
{b.meta}
+
+ {b.live ? '走进这本书 →' : '正在生长 →'} +
+ + ))} +
+ +
越老的书,年轮越厚。
+
+
+ ) +} diff --git a/src/GenjiReader.css b/src/GenjiReader.css new file mode 100644 index 0000000..db9751d --- /dev/null +++ b/src/GenjiReader.css @@ -0,0 +1,406 @@ +@keyframes layerIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: none; + } +} + +.reader-page { + min-height: 100vh; + font-family: 'Noto Sans SC', sans-serif; + color: var(--ink); +} + +.onum { + font-family: Spectral, 'Noto Sans SC', serif; + font-feature-settings: 'onum'; +} + +.cin { + color: var(--cinnabar); + cursor: pointer; +} + +.reader-slider { + position: sticky; + top: 0; + z-index: 10; + background: var(--paper); + transition: background-color 0.5s ease; + padding: 12px 44px 14px 44px; + border-bottom: 0.5px solid var(--line); +} + +.reader-slider-inner { + max-width: 1040px; + margin: 0 auto; +} + +.reader-slider-labels { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: 11px; + color: var(--ink-light); + margin-bottom: 4px; +} + +.reader-slider-hint { + font-size: 12px; + letter-spacing: 0.06em; + transition: opacity 0.4s ease; +} + +.reader-slider-row { + display: flex; + align-items: flex-start; + gap: 16px; +} + +.reader-step { + font-family: Spectral, serif; + font-size: 18px; + line-height: 26px; + color: var(--ink-light); + cursor: pointer; + user-select: none; + transition: color 0.3s ease; +} +.reader-step:hover { + color: var(--cinnabar); +} + +.reader-track { + position: relative; + height: 38px; + cursor: ew-resize; + touch-action: none; + outline: none; + flex: 1; +} + +.reader-track-base { + position: absolute; + left: 0; + right: 0; + top: 12px; + height: 2px; + background: var(--line); +} + +.reader-track-fill { + position: absolute; + left: 0; + top: 12px; + height: 2px; + background: var(--u-soft); +} + +.reader-tick { + position: absolute; + top: 5px; + width: 16px; + height: 16px; + margin-left: -8px; + transition: opacity 0.5s ease; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.reader-tick-dot { + width: 4px; + height: 4px; + border-radius: 50%; +} + +.reader-handle { + position: absolute; + top: 4px; + margin-left: -9px; + pointer-events: none; +} + +.reader-handle-ring { + width: 16px; + height: 16px; + border-radius: 50%; + border: 1.5px solid var(--cinnabar); + background: var(--paper); + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; +} + +.reader-handle-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--cinnabar); +} + +.reader-handle-label { + font-size: 12px; + color: var(--cinnabar); + white-space: nowrap; + margin-top: 3px; + transform: translateX(calc(-50% + 8px)); + text-align: center; +} + +.reader-body { + display: flex; + justify-content: center; + align-items: flex-start; + padding: 88px 44px 140px 44px; +} + +.reader-column { + flex: 0 1 640px; + min-width: 280px; +} + +.reader-crumb { + text-align: center; + font-size: 12px; + letter-spacing: 0.6em; + padding-left: 0.6em; + margin-bottom: 64px; +} + +.reader-crumb-sep { + color: var(--ink-light); +} + +.reader-text { + font-family: 'Noto Serif JP', 'Noto Serif SC', serif; + font-size: 20px; + line-height: 2.5; + letter-spacing: 0.03em; + color: var(--ink); + text-align: center; +} + +.reader-line { + margin-bottom: 14px; +} + +.reader-line-plain { + color: var(--ink); +} + +.reader-openable { + cursor: pointer; + padding-bottom: 3px; + border-bottom: 1px solid transparent; + transition: border-color 0.45s ease; +} +.reader-openable:hover { + border-bottom-color: var(--cinnabar); +} + +.reader-compare-word { + border-bottom: 1px dashed transparent; + transition: border-color 0.6s ease; +} + +.reader-compare-caption { + height: 26px; + margin-top: 20px; + text-align: center; + font-family: 'Noto Serif SC', serif; + font-size: 13px; + color: var(--ink-light); + letter-spacing: 0.06em; + transition: opacity 0.35s ease; +} + +.reader-compare-year { + margin-left: 4px; +} + +.reader-wall { + text-align: center; + margin-top: 60px; + font-size: 13px; +} + +.reader-wall-link { + letter-spacing: 0.12em; + cursor: pointer; +} + +.reader-wall-note { + font-family: 'Noto Serif SC', serif; + color: var(--ink-light); + letter-spacing: 0.05em; +} + +.reader-drawer { + overflow: hidden; + transition: width 0.38s ease-out; + flex-shrink: 0; +} + +.reader-drawer-inner { + width: 392px; + margin-left: 48px; + padding-left: 36px; + border-left: 0.5px solid var(--line); + box-sizing: content-box; +} + +.reader-drawer-head { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + color: var(--ink-light); + margin-bottom: 26px; +} + +.reader-drawer-title { + letter-spacing: 0.14em; +} + +.reader-drawer-actions { + display: flex; + gap: 18px; +} + +.reader-open-text { + font-family: 'Noto Serif JP', 'Noto Serif SC', serif; + font-size: 16px; + line-height: 1.9; + color: var(--ink); + border-left: 2px solid var(--cinnabar); + padding-left: 14px; + margin-bottom: 6px; +} + +.reader-follow { + display: flex; + align-items: center; + gap: 10px; + padding-top: 16px; +} + +.reader-follow-line { + width: 1px; + height: 22px; + background: var(--u-soft); + margin-left: 7px; +} + +.reader-follow span { + font-size: 11px; + color: var(--ink-light); + letter-spacing: 0.08em; +} + +.reader-fold-toggle { + padding: 16px 0; + border-top: 0.5px solid var(--line); + font-size: 12px; + color: var(--ink-light); + letter-spacing: 0.08em; + cursor: pointer; +} +.reader-fold-toggle:hover { + color: var(--ink); +} + +.reader-layer { + position: relative; + padding: 24px 0; + border-top: 0.5px solid var(--line); +} + +.reader-layer-ai-badge { + position: absolute; + top: 24px; + right: 0; + font-size: 10.5px; + color: var(--ink-light); + letter-spacing: 0.12em; + border: 0.5px solid var(--line); + border-radius: 4px; + padding: 2px 7px; +} + +.reader-layer-head { + display: flex; + align-items: baseline; + gap: 10px; + margin-bottom: 10px; + flex-wrap: wrap; +} + +.reader-layer-src { + font-size: 13.5px; + font-weight: 500; +} + +.reader-layer-yl { + font-size: 12px; + color: var(--ink-light); +} + +.reader-layer-badge { + font-size: 11px; + border: 0.5px solid; + border-radius: 4px; + padding: 1px 6px; + line-height: 1.5; +} + +.reader-layer-tag { + font-size: 11px; + letter-spacing: 0.1em; +} + +.reader-layer-body { + font-family: 'Noto Serif SC', serif; + font-size: 14.5px; + line-height: 2.05; + letter-spacing: 0.02em; +} + +.reader-layer-sig { + margin-top: 10px; + font-size: 12.5px; + letter-spacing: 0.04em; +} + +.reader-drawer-foot { + margin-top: 4px; + padding: 26px 0 40px 0; + border-top: 0.5px solid var(--line); + font-size: 13px; +} + +@media (max-width: 900px) { + .reader-body { + flex-direction: column; + align-items: stretch; + padding: 56px 24px 100px 24px; + } + .reader-drawer { + width: auto !important; + margin-top: 48px; + } + .reader-drawer-inner { + width: auto; + margin-left: 0; + padding-left: 0; + border-left: none; + border-top: 0.5px solid var(--line); + padding-top: 24px; + } +} diff --git a/src/GenjiReader.tsx b/src/GenjiReader.tsx new file mode 100644 index 0000000..e0c152d --- /dev/null +++ b/src/GenjiReader.tsx @@ -0,0 +1,588 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import Header from './Header' +import { Link } from './router' +import './GenjiReader.css' + +const MIN = 1000 +const MAX = 2026 + +type Stratum = { + src: string + yl: string + year: number + badge?: string + tag?: '入木' | '青芽' + ai?: boolean + sprout?: boolean + follow?: string + sig?: string + date?: string + body: string +} + +type Sentence = { + text: string + earliest: number | null + strata: Stratum[] +} + +type TimelineEvent = { + y: number + label: string + cin?: boolean +} + +const EVENTS: TimelineEvent[] = [ + { y: 1008, label: '1008 前后 · 《源氏物语》约成书' }, + { y: 1225, label: '1225 · 藤原定家「青表纸本」校订', cin: true }, + { y: 1236, label: '1236 前后 · 源光行·亲行「河内本」' }, + { y: 1362, label: '1362 前后 · 四辻善成《河海抄》' }, + { y: 1796, label: '1796 · 本居宣长《玉の小栉》「物哀」说' }, + { y: 1925, label: '1925 · Arthur Waley 英译' }, + { y: 1976, label: '1976 · Edward Seidensticker 英译' }, + { y: 1983, label: '1983 · 大和和纪《あさきゆめみし》' }, + { y: 2001, label: '2001 · Royall Tyler 英译' }, + { y: 2024, label: '2024 · 苏未「证」入木', cin: true }, + { y: 2026, label: '2026 · 今,青芽初生' }, +] + +const SENTENCES: Sentence[] = [ + { + text: 'いづれの御時にか、女御、更衣あまたさぶらひたまひける中に、いとやむごとなき際にはあらぬが、すぐれて時めきたまふありけり。', + earliest: 1225, + strata: [ + { + src: '四辻善成《河海抄》', + yl: '1362 前后', + year: 1362, + badge: '注', + body: '「女御」「更衣」皆后宫位阶,女御高而更衣次之。开篇即写帝独宠位阶不高者,后文满宫的嫉妒与非议,皆由此一句种下。', + }, + { + src: '本居宣长《玉の小栉》', + yl: '1796', + year: 1796, + badge: '注', + body: '不书年号朝代,只说「不知是哪一朝」,正是「物哀」笔法——让读者一开始就脱开具体的史实时间,进入普遍的人情之中。', + }, + { + src: '版本对照', + yl: '1225 起', + year: 1225, + ai: true, + body: '「候ひたまひける」在部分河内本系抄本中或作「さぶらひたまひける」——同一敬语动词的两种假名书写,是写本时代最常见的异文类型之一。青表纸本与河内本自此分流,此后近八百年的刊本多循青表纸本一系。', + }, + { + src: 'Arthur Waley', + yl: '1925', + year: 1925, + badge: '注', + body: '“At the Court of an Emperor (he lived it matters not when) there was, among the many gentlewomen of the Wisteria Chamber…” 意译弱化了「女御」「更衣」的位阶差异,让英语读者先读懂故事,再读懂制度。', + }, + { + src: '苏未', + yl: '2024', + year: 2024, + badge: '证', + tag: '入木', + sig: '苏未', + date: '2024 年 秋', + body: '母亲走后重读这一句,忽然明白开篇「不知是哪一朝」的写法,是紫式部在提醒我们:接下来要写的不是历史,是任何时代都会发生的偏爱与嫉妒——包括我自己家里那种。', + }, + { + src: '陆知遥', + yl: '2026', + year: 2026, + badge: '疑', + tag: '青芽', + sprout: true, + follow: '2 年后 · 接续 苏未', + sig: '陆知遥', + date: '2026 年 春', + body: 'Waley 后来把桐壺更衣译成「the lady of the Paulownia Court」,可原文从没给过她一个名字。是我们比紫式部更想知道她是谁,还是没有名字,才是这本书真正想写的?', + }, + ], + }, + { + text: 'はじめより我はと思ひ上がりたまへる御方々、めざましきものにおとしめそねみたまふ。', + earliest: 1362, + strata: [ + { + src: '四辻善成《河海抄》', + yl: '1362 前后', + year: 1362, + badge: '注', + body: '平安朝的后妃多由外戚家族送入宫中,恩宠背后连着一整个家族的政治筹码。这一句的嫉妒,从不只是私情,也是家族之间的代理战争。', + }, + { + src: '林见山', + yl: '2025', + year: 2025, + badge: '疑', + tag: '青芽', + sprout: true, + sig: '林见山', + date: '2025 年 冬', + body: '「めざましきもの」——看不顺眼到近乎刺眼的嫉妒。放到今天的职场里读,竟一点也不陌生:恩宠有限,资源有限,嫉妒从来都精确计算得很。', + }, + ], + }, + { + text: '同じほど、それより下臈の更衣たちは、ましてやすからず。', + earliest: null, + strata: [], + }, + { + text: '朝夕の宮仕へにつけても、人の心をのみ動かし、恨みを負ふ積もりにやありけむ、いと篤しくなりゆき、もの心細げに里がちなるを、いよいよあかずあはれなるものに思ほして、人のそしりをもえ憚らせたまはず、世のためしにもなりぬべき御もてなしなり。', + earliest: 1796, + strata: [ + { + src: '本居宣长《玉の小栉》', + yl: '1796', + year: 1796, + badge: '注', + body: '「あはれなるもの」——全书反复出现数百次的「あはれ」,第一次在此显影:帝越是被人非议,越是心疼更衣,这种明知不可为而更深陷的情感,正是「物哀」的原型。', + }, + { + src: 'Edward Seidensticker', + yl: '1976', + year: 1976, + badge: '注', + body: '“It may have been because she had to be conscious always of this displeasure that…” 尽量贴合原文的迂回句法,把「恨みを負ふ積もりにや」译得同样犹疑不定——他不替紫式部把话说满。', + }, + { + src: '苏未', + yl: '2024', + year: 2024, + badge: '证', + tag: '入木', + sig: '苏未', + date: '2024 年 秋', + body: '照顾一个人到「もの心细げに里がちなる」的地步,身边的闲话只会越来越多——这句话让我想起自己陪病房那半年,越用力,越被说闲话,越被说,越不敢松手。', + }, + ], + }, +] + +function pct(y: number) { + return (((y - MIN) / (MAX - MIN)) * 100).toFixed(2) + '%' +} + +function yearLabel(y: number) { + const yr = Math.round(y) + return yr >= MAX ? '今 · 2026' : '公元 ' + yr +} + +export default function GenjiReader() { + const [night, setNight] = useState(false) + const [displayYear, setDisplayYear] = useState(MAX) + const [targetYear, setTargetYear] = useState(MAX) + const [sweeping, setSweeping] = useState(true) + const [dragged, setDragged] = useState(false) + const [hoverTick, setHoverTick] = useState('') + const [open, setOpen] = useState(null) + const [layersVisible, setLayersVisible] = useState(false) + const [foldAi, setFoldAi] = useState(false) + const [compareIdx, setCompareIdx] = useState(null) + const [wallFoot, setWallFoot] = useState(false) + const [wallDrawer, setWallDrawer] = useState(false) + + const reducedRef = useRef(false) + const draggingRef = useRef(false) + const sweepRafRef = useRef(0) + const layerTimerRef = useRef(0) + + const toggleNight = useCallback(() => { + setNight((prev) => { + const next = !prev + if (next) document.body.setAttribute('data-night', '1') + else document.body.removeAttribute('data-night') + return next + }) + }, []) + + // Intro sweep: the axis grows from 1000 to today on first load, echoing 老树's "growth" motif. + useEffect(() => { + reducedRef.current = !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches + + if (reducedRef.current) { + setSweeping(false) + setDisplayYear(MAX) + setTargetYear(MAX) + } else { + setDisplayYear(MIN) + setTargetYear(MIN) + const dur = 3600 + const delay = 500 + const t0 = performance.now() + const step = (t: number) => { + const p = Math.min(1, (t - t0 - delay) / dur) + if (p < 0) { + sweepRafRef.current = requestAnimationFrame(step) + return + } + const e = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2 + const y = MIN + (MAX - MIN) * e + if (p < 1) { + setDisplayYear(y) + setTargetYear(y) + sweepRafRef.current = requestAnimationFrame(step) + } else { + setDisplayYear(MAX) + setTargetYear(MAX) + setSweeping(false) + } + } + sweepRafRef.current = requestAnimationFrame(step) + } + + const onKey = (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + const d = e.key === 'ArrowLeft' ? -20 : 20 + setTargetYear((y) => Math.max(MIN, Math.min(MAX, y + d))) + setDragged(true) + } else if (e.key === 'Escape') { + setOpen(null) + } + } + window.addEventListener('keydown', onKey) + + return () => { + cancelAnimationFrame(sweepRafRef.current) + window.removeEventListener('keydown', onKey) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Ease displayYear toward targetYear once the intro sweep has finished. + useEffect(() => { + if (sweeping) return + let raf = 0 + const loop = () => { + raf = requestAnimationFrame(loop) + setDisplayYear((dy) => { + const diff = targetYear - dy + if (Math.abs(diff) > 0.4) return dy + diff * (reducedRef.current ? 1 : 0.12) + return targetYear + }) + } + raf = requestAnimationFrame(loop) + return () => cancelAnimationFrame(raf) + }, [sweeping, targetYear]) + + const yearFromClientX = useCallback((clientX: number, rect: DOMRect) => { + const p = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) + return Math.round(MIN + p * (MAX - MIN)) + }, []) + + const trackDown = useCallback( + (e: React.PointerEvent) => { + if (sweeping) { + cancelAnimationFrame(sweepRafRef.current) + setSweeping(false) + } + e.currentTarget.setPointerCapture(e.pointerId) + draggingRef.current = true + const rect = e.currentTarget.getBoundingClientRect() + setTargetYear(yearFromClientX(e.clientX, rect)) + setDragged(true) + }, + [sweeping, yearFromClientX], + ) + + const trackMove = useCallback( + (e: React.PointerEvent) => { + if (!draggingRef.current) return + const rect = e.currentTarget.getBoundingClientRect() + setTargetYear(yearFromClientX(e.clientX, rect)) + }, + [yearFromClientX], + ) + + const trackUp = useCallback(() => { + draggingRef.current = false + setTargetYear((t) => { + let best: number | null = null + for (const ev of EVENTS) { + if (best === null || Math.abs(ev.y - t) < Math.abs(best - t)) best = ev.y + } + return best !== null && Math.abs(best - t) <= 12 ? best : t + }) + }, []) + + const stepEvent = useCallback((dir: number) => { + setSweeping(false) + setDragged(true) + setTargetYear((t) => { + const ys = EVENTS.map((e) => e.y).sort((a, b) => a - b) + if (dir > 0) { + const n = ys.find((y) => y > t + 0.5) + return n ?? MAX + } + const p = [...ys].reverse().find((y) => y < t - 0.5) + return p ?? MIN + }) + }, []) + + const openRoom = useCallback((i: number) => { + setOpen(i) + setLayersVisible(false) + setWallDrawer(false) + window.clearTimeout(layerTimerRef.current) + layerTimerRef.current = window.setTimeout(() => setLayersVisible(true), 60) + }, []) + + const dy = displayYear + const yr = Math.round(dy) + const currentSentence = open != null ? SENTENCES[open] : null + + const layers = useMemo(() => { + if (!currentSentence) return [] + return currentSentence.strata.map((L, i) => { + const folded = !!L.ai && foldAi + const on = L.year <= dy + 0.5 + return { + ...L, + idx: i, + folded, + full: !folded, + opacity: on ? 1 : 0.13, + delay: i * 0.07 + 's', + lineTop: i === 0 || L.follow ? 'transparent' : 'var(--line)', + srcColor: L.ai ? 'var(--ink-light)' : 'var(--ink)', + bodyColor: L.ai ? 'var(--ink-light)' : 'var(--ink)', + accent: L.sprout ? 'var(--verdigris)' : 'var(--cinnabar)', + sigLine: L.tag ? `—— ${L.sig} · ${L.date}` : '', + } + }) + }, [currentSentence, foldAi, dy]) + + const underlineColor = (i: number) => { + const s = SENTENCES[i] + if (!s.earliest) return 'transparent' + if (open === i) return 'var(--cinnabar)' + return dy + 0.5 >= s.earliest ? 'var(--u-soft)' : 'transparent' + } + + const hintLabel = sweeping + ? '一千零二十年,正在生长——' + : hoverTick || (dragged ? '' : '点一枚刻度、按 ‹ › 逐层走,或拖动圆点') + + const compareActive = compareIdx != null && dy + 0.5 >= 1225 + + return ( +
+
+ +
+
+
+ 1000 + {hintLabel} + +
+
+ stepEvent(-1)}> + ‹ + +
+
+
+ {EVENTS.map((ev, i) => ( +
{ + setSweeping(false) + setDragged(true) + setTargetYear(ev.y) + }} + onMouseEnter={() => setHoverTick(ev.label)} + onMouseLeave={() => setHoverTick('')} + > +
+
+ ))} +
+
+
+
+
{yearLabel(dy)}
+
+
+ stepEvent(1)}> + › + +
+
+
+ +
+
+
+ + 源氏物語 + + · 第一帖 桐壺 +
+ +
+
+ openRoom(0)} + > + いづれの御時にか、女御、 + = 1225 ? 'var(--cinnabar)' : 'transparent' }} + onMouseEnter={() => dy >= 1225 && setCompareIdx(0)} + onMouseLeave={() => setCompareIdx(null)} + > + 更衣あまたさぶらひたまひける + + 中に、いとやむごとなき際にはあらぬが、すぐれて時めきたまふありけり。 + +
+
+ openRoom(1)} + > + はじめより我はと思ひ上がりたまへる御方々、めざましきものにおとしめそねみたまふ。 + +
+
同じほど、それより下臈の更衣たちは、ましてやすからず。
+
+ openRoom(3)} + > + 朝夕の宮仕へにつけても、人の心をのみ動かし、恨みを負ふ積もりにやありけむ、いと篤しくなりゆき、もの心細げに里がちなるを、いよいよあかずあはれなるものに思ほして、人のそしりをもえ憚らせたまはず、世のためしにもなりぬべき御もてなしなり。 + +
+
+ +
+ 青表纸本「候ひたまひける」 · 河内本系「さぶらひたまひける」 + 1225 年校订,此后两条源流并行至今 +
+ +
+ {!wallFoot ? ( + setWallFoot(true)}> + 抄录本帖,获得落笔资格 → + + ) : ( + + 读是自由的。落笔与问路,需要一个名字。 → 留下名字 + + )} +
+
+ +
+
+
+ 一句话的地层 +
+ setFoldAi((v) => !v)}> + {foldAi ? '展开底图' : '折叠底图'} + + setOpen(null)}> + 收起 + +
+
+ + {currentSentence && ( + <> +
{currentSentence.text}
+ {layersVisible && ( +
+ {layers.map((L) => ( +
+
+ {L.follow && ( +
+
+ {L.follow} +
+ )} + {L.folded ? ( +
setFoldAi((v) => !v)} + > + 底图 · AI 整理 · 已折叠 → +
+ ) : ( +
+ {L.ai &&
底图 · AI 整理
} +
+ + {L.src} + + {L.yl} + {L.badge && ( + + {L.badge} + + )} + {L.tag && ( + + {L.tag} + + )} +
+
+ {L.body} +
+ {L.sigLine && ( +
+ {L.sigLine} +
+ )} +
+ )} +
+
+ ))} +
+ {!wallDrawer ? ( + setWallDrawer(true)}> + 在此落笔 → + + ) : ( + + 读是自由的。落笔与问路,需要一个名字。{' '} + → 留下名字 + + )} +
+
+ )} + + )} +
+
+
+
+ ) +} diff --git a/src/Header.css b/src/Header.css new file mode 100644 index 0000000..f04a4e1 --- /dev/null +++ b/src/Header.css @@ -0,0 +1,44 @@ +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 26px 44px; +} + +.site-brand { + display: flex; + align-items: center; + gap: 12px; + text-decoration: none; +} + +.site-brand-name { + font-family: 'Noto Serif SC', serif; + font-size: 17px; + font-weight: 500; + letter-spacing: 0.3em; + color: var(--ink); +} + +.site-brand-tagline { + font-size: 12px; + color: var(--ink-light); + letter-spacing: 0.08em; + margin-left: 4px; +} + +.site-nav { + display: flex; + gap: 28px; + font-size: 13px; + color: var(--ink-light); +} + +@media (max-width: 520px) { + .site-header { + padding: 20px 24px; + } + .site-brand-tagline { + display: none; + } +} diff --git a/src/Header.tsx b/src/Header.tsx new file mode 100644 index 0000000..fce5820 --- /dev/null +++ b/src/Header.tsx @@ -0,0 +1,43 @@ +import { Link } from './router' +import './Header.css' + +/** Shared chrome for the bookshelf / book-detail / reader pages. */ +export default function Header({ + home = '/books', + night, + onToggleNight, +}: { + home?: string + night: boolean + onToggleNight: () => void +}) { + return ( +
+ + + 老树 + 千年经典的活体年轮库 + +
+ { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggleNight() + } + }} + > + {night ? '昼读' : '夜读'} + +
+
+ ) +} diff --git a/src/MyTree.tsx b/src/MyTree.tsx index e773e3c..2180eb9 100644 --- a/src/MyTree.tsx +++ b/src/MyTree.tsx @@ -1,4 +1,5 @@ import { useCallback, useState } from 'react' +import { Link } from './router' import './MyTree.css' /** @@ -178,6 +179,9 @@ export default function MyTree() { 老树