@hanology/cham-browser 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/cli.d.ts +2 -0
  2. package/dist/cli.js +191 -0
  3. package/dist/cli.js.map +1 -0
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.js +2 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/pipeline.d.ts +14 -0
  8. package/dist/pipeline.js +377 -0
  9. package/dist/pipeline.js.map +1 -0
  10. package/package.json +22 -3
  11. package/template/index.html +29 -0
  12. package/template/src/App.vue +29 -0
  13. package/template/src/components/AnnotationLayerSelector.vue +66 -0
  14. package/template/src/components/AnnotationTooltip.vue +189 -0
  15. package/template/src/components/BookCard.vue +85 -0
  16. package/template/src/components/HorizontalDisplay.vue +100 -0
  17. package/template/src/components/PoemCard.vue +131 -0
  18. package/template/src/components/PronunciationGroup.vue +45 -0
  19. package/template/src/components/ReadingToolbar.vue +131 -0
  20. package/template/src/components/SectionBlock.vue +142 -0
  21. package/template/src/components/SideNav.vue +291 -0
  22. package/template/src/components/VerticalScroll.vue +120 -0
  23. package/template/src/composables/useAnnotationRenderer.ts +158 -0
  24. package/template/src/composables/useBook.ts +93 -0
  25. package/template/src/composables/useData.ts +41 -0
  26. package/template/src/composables/useHorizontalScroll.ts +60 -0
  27. package/template/src/composables/useLibrary.ts +40 -0
  28. package/template/src/composables/usePageLayout.ts +25 -0
  29. package/template/src/composables/useReadingMode.ts +70 -0
  30. package/template/src/composables/useTitle.ts +5 -0
  31. package/template/src/main.ts +22 -0
  32. package/template/src/router.ts +29 -0
  33. package/template/src/shims-vue.d.ts +7 -0
  34. package/template/src/styles/main.css +136 -0
  35. package/template/src/types.ts +164 -0
  36. package/template/src/utils/annotationParser.ts +58 -0
  37. package/template/src/utils/chineseNumber.ts +41 -0
  38. package/template/src/views/AuthorView.vue +338 -0
  39. package/template/src/views/BookHome.vue +375 -0
  40. package/template/src/views/LibraryHome.vue +419 -0
  41. package/template/src/views/PieceView.vue +793 -0
  42. package/src/index.ts +0 -20
  43. package/tsconfig.json +0 -16
@@ -0,0 +1,793 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, reactive, watch, onMounted, onUnmounted } from 'vue'
3
+ import { useRouter } from 'vue-router'
4
+ import { useBook } from '../composables/useBook'
5
+ import { useTitle } from '../composables/useTitle'
6
+ import { useReadingMode } from '../composables/useReadingMode'
7
+ import { useHorizontalScroll } from '../composables/useHorizontalScroll'
8
+ import { useAnnotationTooltip } from '../composables/useAnnotationRenderer'
9
+ import { useData } from '../composables/useData'
10
+ import VerticalScroll from '../components/VerticalScroll.vue'
11
+ import HorizontalDisplay from '../components/HorizontalDisplay.vue'
12
+ import SectionBlock from '../components/SectionBlock.vue'
13
+ import AnnotationTooltip from '../components/AnnotationTooltip.vue'
14
+ import AnnotationLayerSelector from '../components/AnnotationLayerSelector.vue'
15
+ import SideNav from '../components/SideNav.vue'
16
+ import type { Piece, Annotation, AnnotationLayer } from '../types'
17
+
18
+ const props = defineProps<{ bookId: string; num: string | number }>()
19
+ const router = useRouter()
20
+ const { getPiece, pieces, meta, load, getAdjacentNums } = useBook()
21
+ await load(props.bookId)
22
+
23
+ const { layout } = useReadingMode()
24
+ const vPageRef = ref<HTMLElement | null>(null)
25
+ const vScroll = useHorizontalScroll(vPageRef)
26
+
27
+ const authorPaneOpen = ref(false)
28
+ const selectedAuthorId = ref('')
29
+ const tooltip = reactive(useAnnotationTooltip())
30
+ const titleCollapsed = ref(false)
31
+ const vTitleRef = ref<HTMLElement | null>(null)
32
+
33
+ onMounted(() => {
34
+ if (!vTitleRef.value) return
35
+ const observer = new IntersectionObserver(
36
+ ([entry]) => { titleCollapsed.value = !entry.isIntersecting },
37
+ { threshold: 0 }
38
+ )
39
+ observer.observe(vTitleRef.value)
40
+ onUnmounted(() => observer.disconnect())
41
+ })
42
+
43
+ const piece = computed<Piece | undefined>(() => {
44
+ const n = typeof props.num === 'string' ? parseInt(props.num, 10) : props.num
45
+ return getPiece(n)
46
+ })
47
+
48
+ const adjacent = computed(() => {
49
+ const n = typeof props.num === 'string' ? parseInt(props.num, 10) : props.num
50
+ return getAdjacentNums(n)
51
+ })
52
+
53
+ const pageTitle = computed(() => piece.value
54
+ ? `${piece.value.title}·${piece.value.author} — ${meta.value?.title}`
55
+ : meta.value?.title || ''
56
+ )
57
+ useTitle(pageTitle.value)
58
+
59
+ const isVertical = computed(() => layout.value === 'vertical')
60
+
61
+ const annotationLayers = computed<AnnotationLayer[]>(() => piece.value?.annotationLayers || [])
62
+ const hasLayers = computed(() => annotationLayers.value.length > 1)
63
+ const activeLayerIds = ref<string[]>([])
64
+
65
+ function initLayers() {
66
+ if (hasLayers.value && activeLayerIds.value.length === 0) {
67
+ activeLayerIds.value = annotationLayers.value
68
+ .filter(l => l.enabled)
69
+ .map(l => l.id)
70
+ }
71
+ }
72
+
73
+ const mergedAnnotations = computed<Annotation[]>(() => {
74
+ if (!hasLayers.value) return piece.value?.annotations || []
75
+ const result: Annotation[] = []
76
+ for (const layer of annotationLayers.value) {
77
+ if (!activeLayerIds.value.includes(layer.id)) continue
78
+ for (const ann of layer.annotations) {
79
+ result.push(ann)
80
+ }
81
+ }
82
+ for (const ann of piece.value?.annotations || []) {
83
+ result.push(ann)
84
+ }
85
+ return result
86
+ })
87
+
88
+ const layerLabels = computed(() => {
89
+ const labels: Record<string, string> = {}
90
+ for (const layer of annotationLayers.value) {
91
+ if (layer.id !== 'default') labels[layer.id] = layer.label
92
+ }
93
+ return labels
94
+ })
95
+
96
+ const layerAnnotationBlocks = computed(() => {
97
+ if (!hasLayers.value) return []
98
+ const result: { label: string; text: string }[] = []
99
+ const activeLayers = annotationLayers.value.filter(l => activeLayerIds.value.includes(l.id) && l.id !== 'default')
100
+ for (const layer of activeLayers) {
101
+ if (layer.annotations.length === 0) continue
102
+ const lines: string[] = []
103
+ let n = 1
104
+ for (const ann of layer.annotations) {
105
+ const headword = getHeadword(ann)
106
+ lines.push(`${n}.${headword}:${ann.text}`)
107
+ n++
108
+ }
109
+ result.push({ label: layer.label, text: lines.join('\n') })
110
+ }
111
+ return result
112
+ })
113
+
114
+ function getHeadword(ann: Annotation): string {
115
+ const p = piece.value
116
+ if (!p) return ''
117
+ if (ann.range.scope === 'title') {
118
+ return p.title.slice(ann.range.start ?? 0, ann.range.end)
119
+ }
120
+ if (ann.range.scope === 'verse' && ann.range.verseIndex !== undefined) {
121
+ const verse = p.verses[ann.range.verseIndex]
122
+ if (verse) return verse.text.slice(ann.range.start ?? 0, ann.range.end)
123
+ }
124
+ return ''
125
+ }
126
+
127
+ // Initialize layers when piece loads
128
+ watch(() => piece.value, () => initLayers(), { immediate: true })
129
+
130
+ const SECTION_META: Record<string, { label: string; special: boolean }> = {
131
+ background: { label: '背景資料', special: false },
132
+ analysis: { label: '賞析重點', special: false },
133
+ preparation: { label: '預習活動', special: true },
134
+ follow_up: { label: '跟進活動', special: true },
135
+ think_questions: { label: '想一想', special: true },
136
+ }
137
+
138
+ const proseSections = computed(() => {
139
+ const ss = piece.value?.structuredSections
140
+ if (ss && ss.length > 0) {
141
+ return ss.filter(s => s.key !== 'author_bio' && s.body)
142
+ }
143
+ // Fallback to legacy sections record
144
+ const sections = piece.value?.sections || {}
145
+ const result: { key: string; title: string; body: string; order: number; special: boolean }[] = []
146
+ for (const [key, label] of Object.entries({ background: '背景資料', analysis: '賞析重點', preparation: '預習活動', follow_up: '跟進活動', think_questions: '想一想' })) {
147
+ if (sections[key]) {
148
+ const meta = SECTION_META[key]
149
+ result.push({ key, title: label, body: sections[key], order: meta ? (key === 'background' ? 1 : key === 'analysis' ? 2 : 3) : 99, special: meta?.special ?? false })
150
+ }
151
+ }
152
+ return result
153
+ })
154
+
155
+ function handleAnnotationHover(event: MouseEvent, annotations: Annotation[]) {
156
+ tooltip.show(event, annotations)
157
+ }
158
+ function handleAnnotationLeave() {
159
+ if (window.innerWidth >= 768) tooltip.hide()
160
+ }
161
+ function handleAnnotationTap(event: MouseEvent, annotations: Annotation[]) {
162
+ tooltip.toggle(event, annotations)
163
+ }
164
+ function dismissTooltip() { tooltip.hide() }
165
+ const { getAuthor, loadShared } = useData()
166
+ await loadShared()
167
+
168
+ const selectedAuthorName = computed(() => {
169
+ if (!selectedAuthorId.value) return piece.value?.author || ''
170
+ const c = piece.value?.contributors?.find(x => x.id === selectedAuthorId.value)
171
+ return c?.name || piece.value?.author || ''
172
+ })
173
+ const selectedAuthorBio = computed(() => {
174
+ const name = selectedAuthorName.value
175
+ const a = getAuthor(name)
176
+ return a?.bio || piece.value?.sections?.author_bio || ''
177
+ })
178
+
179
+ function openAuthorPane(id?: string) {
180
+ selectedAuthorId.value = id || piece.value?.authorId || ''
181
+ authorPaneOpen.value = true
182
+ }
183
+ function closeAuthorPane() { authorPaneOpen.value = false; selectedAuthorId.value = '' }
184
+ function goBack() { router.push(`/${props.bookId}`) }
185
+ function goHome() { router.push('/') }
186
+
187
+ function navigate(delta: number) {
188
+ if (!piece.value) return
189
+ const target = delta < 0 ? adjacent.value.prev : adjacent.value.next
190
+ if (target !== null) router.push(`/${props.bookId}/${target}`)
191
+ }
192
+
193
+ const contributorGroups = computed(() => {
194
+ const c = piece.value?.contributors
195
+ if (!c || c.length <= 1) return []
196
+ const groups = new Map<string, string[]>()
197
+ for (const x of c) {
198
+ const t = x.title || '作者'
199
+ if (!groups.has(t)) groups.set(t, [])
200
+ groups.get(t)!.push(x.name)
201
+ }
202
+ return [...groups.entries()].map(([title, names]) => ({ title, names }))
203
+ })
204
+
205
+ const authorDisplay = computed(() => {
206
+ const c = piece.value?.contributors
207
+ if (!c || c.length <= 1) return piece.value?.author || ''
208
+ return contributorGroups.value.map(g => `${g.title} ${g.names.join(' ')}`).join(' ')
209
+ })
210
+
211
+ function tcy(n: number): string {
212
+ const s = String(n)
213
+ return s.length <= 2 ? `<span style="text-combine-upright:all">${s}</span>` : s
214
+ }
215
+ </script>
216
+
217
+ <template>
218
+ <div v-if="piece">
219
+ <!-- ═══════ 直排模式 ═══════ -->
220
+ <div v-if="isVertical" class="v-root">
221
+ <SideNav
222
+ :context="`${piece.num}. ${piece.title}`"
223
+ :poem-title="piece.title"
224
+ :poem-author="piece.author"
225
+ :title-collapsed="titleCollapsed"
226
+ @back="goBack"
227
+ @home="goHome"
228
+ />
229
+ <div ref="vPageRef" class="v-page">
230
+ <section ref="vTitleRef" class="v-title-col">
231
+ <h1 class="v-poem-title">{{ piece.title }}</h1>
232
+ <template v-if="piece.contributors && piece.contributors.length > 1">
233
+ <div v-for="group in contributorGroups" :key="group.title" class="v-author-group">
234
+ <span class="v-author-role">{{ group.title }}</span>
235
+ <span v-for="name in group.names" :key="name" class="v-poem-author" @click="openAuthorPane(piece.contributors!.find(c => c.name === name)?.id)">{{ name }}</span>
236
+ </div>
237
+ </template>
238
+ <span v-else class="v-poem-author" @click="openAuthorPane">{{ piece.author }}</span>
239
+ <div v-if="piece.source?.textRef" class="v-source-link" @click="router.push(`/${piece.source.textRef}`)">
240
+ ← {{ meta?.title }}
241
+ </div>
242
+ <div class="v-poem-meta">
243
+ <span class="v-meta-item" v-html="tcy(piece.verses.length) + ' 段'" />
244
+ <span class="v-meta-item" v-html="piece.annotations.length > 0 ? tcy(piece.annotations.length) + ' 注' : '無注'" />
245
+ </div>
246
+ </section>
247
+
248
+ <section class="v-poem-col">
249
+ <VerticalScroll
250
+ :title="''"
251
+ :author="''"
252
+ :verses="piece.verses"
253
+ :author-initial="piece.author?.charAt(0) || '詩'"
254
+ :annotations="mergedAnnotations"
255
+ @annotation-hover="handleAnnotationHover"
256
+ @annotation-leave="handleAnnotationLeave"
257
+ @annotation-tap="handleAnnotationTap"
258
+ @open-author="openAuthorPane"
259
+ />
260
+ </section>
261
+
262
+ <SectionBlock
263
+ num=""
264
+ label="注釋"
265
+ :special="false"
266
+ :text="piece.sections.annotations || ''"
267
+ :is-annotations="true"
268
+ :vertical="true"
269
+ class="v-section"
270
+ />
271
+
272
+ <div v-if="hasLayers" class="v-layers-section">
273
+ <AnnotationLayerSelector
274
+ :layers="annotationLayers"
275
+ v-model:activeIds="activeLayerIds"
276
+ />
277
+ <SectionBlock
278
+ v-for="block in layerAnnotationBlocks"
279
+ :key="block.label"
280
+ num=""
281
+ :label="block.label"
282
+ :special="false"
283
+ :text="block.text"
284
+ :is-annotations="true"
285
+ :vertical="true"
286
+ class="v-section"
287
+ />
288
+ </div>
289
+
290
+ <SectionBlock
291
+ v-for="(sec, idx) in proseSections"
292
+ :key="sec.key"
293
+ :num="String(idx + 1).padStart(2, '0')"
294
+ :label="sec.title"
295
+ :special="SECTION_META[sec.key]?.special ?? false"
296
+ :text="sec.body"
297
+ :is-annotations="false"
298
+ :vertical="true"
299
+ class="v-section"
300
+ />
301
+
302
+ <nav class="v-nav">
303
+ <button v-if="adjacent.prev !== null" class="v-nav-btn" @click="navigate(-1)">
304
+ <span class="v-nav-dir">▲</span>
305
+ <span class="v-nav-label">上一篇</span>
306
+ <span class="v-nav-title">{{ getPiece(adjacent.prev)?.title }}</span>
307
+ </button>
308
+ <div v-else class="v-nav-spacer" />
309
+ <button v-if="adjacent.next !== null" class="v-nav-btn" @click="navigate(1)">
310
+ <span class="v-nav-dir">▼</span>
311
+ <span class="v-nav-label">下一篇</span>
312
+ <span class="v-nav-title">{{ getPiece(adjacent.next)?.title }}</span>
313
+ </button>
314
+ </nav>
315
+ </div>
316
+
317
+ <AnnotationTooltip
318
+ :visible="tooltip.visible"
319
+ :annotations="tooltip.items"
320
+ :layer-labels="layerLabels"
321
+ :style="tooltip.style"
322
+ @close="dismissTooltip"
323
+ />
324
+
325
+ <Teleport to="body">
326
+ <div v-if="authorPaneOpen" class="v-overlay" @click="closeAuthorPane">
327
+ <div class="v-author-pane" @click.stop>
328
+ <button class="v-pane-close" @click="closeAuthorPane">✕</button>
329
+ <div class="v-pane-header">
330
+ <div class="v-pane-name">{{ selectedAuthorName }}</div>
331
+ </div>
332
+ <div v-if="selectedAuthorBio" class="v-pane-bio">
333
+ <div v-for="p in selectedAuthorBio.split('\n').filter(l => l.trim())" :key="p" class="v-pane-p">
334
+ {{ p.trim() }}
335
+ </div>
336
+ </div>
337
+ </div>
338
+ </div>
339
+ </Teleport>
340
+ </div>
341
+
342
+ <!-- ═══════ 橫排模式 ═══════ -->
343
+ <div v-else class="h-root">
344
+ <div class="h-page">
345
+ <nav class="h-nav">
346
+ <div class="h-nav-inner">
347
+ <button class="h-back" @click="goBack">← 返回</button>
348
+ <div class="h-breadcrumb">
349
+ <span v-if="piece.source?.textRef" class="h-source-link" @click="router.push(`/${piece.source.textRef}`)">
350
+ {{ meta?.title }} →
351
+ </span>
352
+ <span class="h-sep">{{ piece.num }}.</span>
353
+ {{ piece.title }}
354
+ <span class="h-sep">·</span>
355
+ <template v-if="piece.contributors && piece.contributors.length > 1">
356
+ <template v-for="(group, gi) in contributorGroups" :key="group.title">
357
+ <span v-if="gi > 0" class="h-sep">|</span>
358
+ <span class="h-author-role">{{ group.title }}</span>
359
+ <span v-for="name in group.names" :key="name" class="h-author-link" @click="openAuthorPane(piece.contributors!.find(c => c.name === name)?.id)">{{ name }}</span>
360
+ </template>
361
+ </template>
362
+ <span v-else class="h-author-link" @click="openAuthorPane">{{ piece.author }}</span>
363
+ </div>
364
+ <div class="h-controls">
365
+ <span class="h-tag">{{ piece.verses.length }} 段</span>
366
+ <span class="h-tag">{{ piece.annotations.length > 0 ? piece.annotations.length + ' 注' : '無注' }}</span>
367
+ </div>
368
+ </div>
369
+ </nav>
370
+
371
+ <div class="h-content">
372
+ <div class="h-poem-block">
373
+ <HorizontalDisplay
374
+ :title="piece.title"
375
+ :author="piece.author"
376
+ :verses="piece.verses"
377
+ :annotations="mergedAnnotations"
378
+ @annotation-hover="handleAnnotationHover"
379
+ @annotation-leave="handleAnnotationLeave"
380
+ @annotation-tap="handleAnnotationTap"
381
+ />
382
+ </div>
383
+
384
+ <div class="h-sections">
385
+ <div v-if="hasLayers" class="h-layers-section">
386
+ <AnnotationLayerSelector
387
+ :layers="annotationLayers"
388
+ v-model:activeIds="activeLayerIds"
389
+ />
390
+ <SectionBlock
391
+ v-for="block in layerAnnotationBlocks"
392
+ :key="block.label"
393
+ num=""
394
+ :label="block.label"
395
+ :special="false"
396
+ :text="block.text"
397
+ :is-annotations="true"
398
+ />
399
+ </div>
400
+
401
+ <SectionBlock
402
+ v-for="(sec, idx) in proseSections"
403
+ :key="sec.key"
404
+ :num="String(idx + 1).padStart(2, '0')"
405
+ :label="sec.title"
406
+ :special="SECTION_META[sec.key]?.special ?? false"
407
+ :text="sec.body"
408
+ :is-annotations="false"
409
+ :style="{ animationDelay: idx * 0.08 + 's' }"
410
+ />
411
+ </div>
412
+
413
+ <div class="h-nav-bottom">
414
+ <button v-if="adjacent.prev !== null" class="h-nav-btn" @click="navigate(-1)">
415
+ <div class="h-nav-label">← 上一篇</div>
416
+ <div class="h-nav-title">{{ getPiece(adjacent.prev)?.title }}</div>
417
+ </button>
418
+ <div v-else />
419
+ <button v-if="adjacent.next !== null" class="h-nav-btn h-nav-next" @click="navigate(1)">
420
+ <div class="h-nav-label">下一篇 →</div>
421
+ <div class="h-nav-title">{{ getPiece(adjacent.next)?.title }}</div>
422
+ </button>
423
+ </div>
424
+ </div>
425
+ </div>
426
+
427
+ <AnnotationTooltip
428
+ :visible="tooltip.visible"
429
+ :annotations="tooltip.items"
430
+ :layer-labels="layerLabels"
431
+ :style="tooltip.style"
432
+ @close="dismissTooltip"
433
+ />
434
+
435
+ <Teleport to="body">
436
+ <div v-if="authorPaneOpen" class="h-overlay" @click="closeAuthorPane">
437
+ <div class="h-pane" @click.stop>
438
+ <button class="h-pane-close" @click="closeAuthorPane">✕</button>
439
+ <div class="h-pane-header">
440
+ <div>
441
+ <div class="h-pane-name">{{ selectedAuthorName }}</div>
442
+ <div class="h-pane-meta">{{ piece.title }} 等</div>
443
+ </div>
444
+ </div>
445
+ <div v-if="selectedAuthorBio" class="h-pane-bio">
446
+ <div v-for="p in selectedAuthorBio.split('\n').filter(l => l.trim())" :key="p" class="h-pane-p">
447
+ {{ p.trim() }}
448
+ </div>
449
+ </div>
450
+ </div>
451
+ </div>
452
+ </Teleport>
453
+ </div>
454
+ </div>
455
+
456
+ <div v-else style="text-align:center;padding-top:120px">
457
+ <p style="font-size:18px;color:var(--ink-faint)">載入中…</p>
458
+ </div>
459
+ </template>
460
+
461
+ <style scoped>
462
+ /* ═══════ 直排模式 ═══════ */
463
+
464
+ .v-page {
465
+ height: 100vh;
466
+ display: flex;
467
+ flex-direction: row-reverse;
468
+ overflow-x: auto;
469
+ overflow-y: hidden;
470
+ margin-right: var(--nav-width, 56px);
471
+ padding: 0;
472
+ background: var(--paper);
473
+ scrollbar-width: thin;
474
+ scrollbar-color: var(--gold) transparent;
475
+ }
476
+ .v-page::-webkit-scrollbar { height: 4px; }
477
+ .v-page::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
478
+
479
+ .v-title-col {
480
+ writing-mode: vertical-rl;
481
+ text-orientation: mixed;
482
+ flex-shrink: 0;
483
+ height: 100vh;
484
+ display: flex;
485
+ align-items: flex-start;
486
+ justify-content: center;
487
+ gap: 16px;
488
+ padding: 40px 24px;
489
+ border-right: 1px solid var(--border);
490
+ }
491
+ .v-poem-title {
492
+ font-size: 40px; font-weight: 900;
493
+ letter-spacing: 12px; color: var(--ink);
494
+ padding-left: 20px;
495
+ border-left: 4px solid var(--vermillion);
496
+ line-height: 1.6;
497
+ }
498
+ .v-poem-author {
499
+ font-size: 24px; font-weight: 400;
500
+ color: var(--ink-light); letter-spacing: 6px;
501
+ cursor: pointer;
502
+ transition: color 0.15s;
503
+ }
504
+ .v-poem-author:hover { color: var(--vermillion); }
505
+ .v-author-group {
506
+ display: flex;
507
+ flex-direction: column;
508
+ align-items: flex-start;
509
+ gap: 4px;
510
+ }
511
+ .v-author-role {
512
+ font-size: 12px; color: var(--ink-faint);
513
+ font-family: var(--sans); letter-spacing: 2px;
514
+ }
515
+ .v-poem-meta {
516
+ display: flex;
517
+ gap: 8px;
518
+ }
519
+ .v-meta-item {
520
+ font-size: 13px; color: var(--ink-faint);
521
+ font-family: var(--sans); letter-spacing: 2px;
522
+ }
523
+
524
+ .v-poem-col {
525
+ flex-shrink: 0;
526
+ display: flex;
527
+ align-items: center;
528
+ padding: 24px;
529
+ }
530
+
531
+ .v-section {
532
+ flex-shrink: 0;
533
+ }
534
+
535
+ .v-layers-section {
536
+ flex-shrink: 0;
537
+ writing-mode: vertical-rl;
538
+ text-orientation: mixed;
539
+ padding: 0 16px;
540
+ display: flex;
541
+ flex-direction: column;
542
+ gap: 8px;
543
+ align-items: flex-start;
544
+ }
545
+
546
+ .v-source-link {
547
+ font-size: 12px;
548
+ color: var(--c-brand);
549
+ cursor: pointer;
550
+ margin-top: 4px;
551
+ opacity: 0.8;
552
+ }
553
+ .v-source-link:hover { opacity: 1; text-decoration: underline; }
554
+
555
+ .v-nav {
556
+ writing-mode: vertical-rl;
557
+ text-orientation: mixed;
558
+ flex-shrink: 0;
559
+ height: 100vh;
560
+ display: flex;
561
+ flex-direction: row;
562
+ align-items: center;
563
+ justify-content: center;
564
+ padding: 24px 12px;
565
+ gap: 32px;
566
+ }
567
+ .v-nav-spacer { flex: 1; }
568
+ .v-nav-btn {
569
+ writing-mode: vertical-rl;
570
+ text-orientation: mixed;
571
+ display: flex;
572
+ flex-direction: column;
573
+ align-items: center;
574
+ gap: 8px;
575
+ padding: 16px 8px;
576
+ background: var(--surface);
577
+ border: 1px solid var(--border-light);
578
+ border-radius: 6px;
579
+ cursor: pointer;
580
+ transition: all 0.2s;
581
+ }
582
+ .v-nav-btn:hover {
583
+ border-color: var(--gold);
584
+ box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.08);
585
+ }
586
+ .v-nav-dir {
587
+ font-size: 16px; color: var(--vermillion);
588
+ }
589
+ .v-nav-label {
590
+ font-size: 11px; color: var(--ink-faint);
591
+ font-family: var(--sans); letter-spacing: 2px;
592
+ }
593
+ .v-nav-title {
594
+ font-size: 18px; font-weight: 700;
595
+ letter-spacing: 3px; color: var(--ink);
596
+ }
597
+
598
+ /* ═══════ 橫排模式 ═══════ */
599
+
600
+ .h-page { min-height: 100vh; }
601
+ .h-nav {
602
+ position: sticky; top: 0; z-index: 100;
603
+ background: var(--paper); opacity: 0.97;
604
+ backdrop-filter: blur(20px);
605
+ border-bottom: 1px solid var(--border-light);
606
+ padding: 0 40px;
607
+ }
608
+ .h-nav-inner {
609
+ max-width: 1200px; margin: 0 auto;
610
+ display: flex; align-items: center;
611
+ height: 56px; gap: 16px;
612
+ }
613
+ .h-back {
614
+ display: inline-flex; align-items: center; gap: 6px;
615
+ padding: 6px 16px; border: 1px solid var(--border);
616
+ border-radius: 2px; background: none;
617
+ font-family: var(--sans); font-size: 13px;
618
+ color: var(--ink-mid); cursor: pointer;
619
+ transition: all 0.2s; white-space: nowrap;
620
+ }
621
+ .h-back:hover { background: var(--ink); color: var(--paper); border-color: var(--ink); }
622
+ .h-breadcrumb { font-size: 15px; font-weight: 600; letter-spacing: 1px; }
623
+ .h-sep { color: var(--ink-faint); font-weight: 300; margin: 0 8px; }
624
+ .h-author-link {
625
+ color: var(--ink-light); font-weight: 400;
626
+ cursor: pointer; transition: color 0.15s;
627
+ }
628
+ .h-author-link:hover { color: var(--vermillion); }
629
+ .h-author-role {
630
+ font-size: 12px; color: var(--ink-faint);
631
+ font-family: var(--sans); letter-spacing: 1px;
632
+ margin-right: 4px;
633
+ }
634
+ .h-controls { margin-left: auto; display: flex; gap: 6px; }
635
+ .h-tag {
636
+ padding: 4px 12px; border: 1px solid var(--border);
637
+ border-radius: 2px; font-family: var(--sans);
638
+ font-size: 12px; color: var(--ink-light); letter-spacing: 1px;
639
+ }
640
+
641
+ .h-content {
642
+ max-width: 1200px; margin: 0 auto; padding: 60px 40px;
643
+ }
644
+ .h-poem-block {
645
+ margin-bottom: 60px; display: flex; justify-content: center;
646
+ }
647
+ .h-sections {
648
+ max-width: min(680px, calc(100vw - 80px));
649
+ margin: 0 auto; padding-bottom: 80px;
650
+ }
651
+
652
+ .h-layers-section {
653
+ margin-bottom: 16px;
654
+ padding: 16px;
655
+ background: var(--surface);
656
+ border-radius: 8px;
657
+ border: 1px solid var(--border-light);
658
+ }
659
+
660
+ .h-source-link {
661
+ color: var(--c-brand);
662
+ cursor: pointer;
663
+ font-size: 13px;
664
+ }
665
+ .h-source-link:hover { text-decoration: underline; }
666
+
667
+ .h-nav-bottom {
668
+ max-width: min(680px, calc(100vw - 80px));
669
+ margin: 0 auto 60px;
670
+ display: flex; justify-content: space-between; gap: 16px;
671
+ }
672
+ .h-nav-btn {
673
+ flex: 1; padding: 16px 24px;
674
+ background: var(--surface); border: 1px solid var(--border-light);
675
+ border-radius: 8px; cursor: pointer;
676
+ transition: all 0.2s ease; font-family: var(--serif);
677
+ text-align: left;
678
+ }
679
+ .h-nav-btn:hover { border-color: var(--gold); box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.08); }
680
+ .h-nav-btn.h-nav-next { text-align: right; }
681
+ .h-nav-label { font-size: 11px; color: var(--ink-faint); font-family: var(--sans); letter-spacing: 2px; margin-bottom: 4px; }
682
+ .h-nav-title { font-size: 16px; font-weight: 600; letter-spacing: 1px; color: var(--ink); }
683
+
684
+ .h-overlay {
685
+ position: fixed; inset: 0;
686
+ background: rgba(var(--shadow-rgb), 0.3);
687
+ z-index: 200;
688
+ display: flex; justify-content: flex-end;
689
+ animation: fadeIn 0.2s ease;
690
+ }
691
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
692
+ .h-pane {
693
+ width: min(420px, 90vw);
694
+ height: 100vh;
695
+ background: var(--paper);
696
+ padding: 32px;
697
+ overflow-y: auto;
698
+ animation: slideIn 0.25s ease;
699
+ box-shadow: -8px 0 32px rgba(var(--shadow-rgb), 0.1);
700
+ }
701
+ @keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
702
+ .h-pane-close {
703
+ display: block; margin-left: auto;
704
+ width: 36px; height: 36px;
705
+ border: 1px solid var(--border); border-radius: 4px;
706
+ background: none; font-size: 16px;
707
+ color: var(--ink-light); cursor: pointer;
708
+ transition: all 0.15s;
709
+ }
710
+ .h-pane-close:hover { background: var(--ink); color: var(--paper); border-color: var(--ink); }
711
+ .h-pane-header {
712
+ display: flex; align-items: center; gap: 20px;
713
+ margin: 24px 0 32px;
714
+ }
715
+ .h-pane-seal {
716
+ width: 64px; height: 64px;
717
+ border: 2px solid var(--vermillion); border-radius: 4px;
718
+ display: flex; align-items: center; justify-content: center;
719
+ font-size: 24px; font-weight: 900;
720
+ color: var(--vermillion); flex-shrink: 0;
721
+ }
722
+ .h-pane-name { font-size: 28px; font-weight: 900; letter-spacing: 4px; color: var(--ink); }
723
+ .h-pane-meta { font-size: 14px; color: var(--ink-faint); letter-spacing: 2px; margin-top: 4px; }
724
+ .h-pane-bio { border-top: 1px solid var(--border); padding-top: 24px; }
725
+ .h-pane-p {
726
+ font-size: 16px; line-height: 2.2;
727
+ color: var(--ink-mid); text-align: justify;
728
+ text-indent: 2em; margin-bottom: 12px;
729
+ }
730
+
731
+ .v-overlay {
732
+ position: fixed; inset: 0;
733
+ background: rgba(var(--shadow-rgb), 0.3);
734
+ z-index: 200;
735
+ display: flex; justify-content: flex-start;
736
+ animation: fadeIn 0.2s ease;
737
+ }
738
+ .v-author-pane {
739
+ writing-mode: vertical-rl;
740
+ text-orientation: mixed;
741
+ height: 100vh;
742
+ background: var(--paper);
743
+ padding: 32px 24px;
744
+ overflow-x: auto;
745
+ box-shadow: 8px 0 32px rgba(var(--shadow-rgb), 0.1);
746
+ animation: slideInV 0.25s ease;
747
+ }
748
+ @keyframes slideInV { from { transform: translateX(-100%); } to { transform: translateX(0); } }
749
+ .v-pane-close {
750
+ display: block;
751
+ width: 32px; height: 32px;
752
+ border: 1px solid var(--border); border-radius: 4px;
753
+ background: none; font-size: 14px;
754
+ color: var(--ink-light); cursor: pointer;
755
+ transition: all 0.15s;
756
+ margin-bottom: 16px;
757
+ }
758
+ .v-pane-close:hover { background: var(--ink); color: var(--paper); border-color: var(--ink); }
759
+ .v-pane-header {
760
+ display: flex;
761
+ flex-direction: column;
762
+ align-items: flex-start;
763
+ margin-bottom: 24px;
764
+ padding-left: 20px;
765
+ border-left: 1px solid var(--border);
766
+ }
767
+ .v-pane-seal {
768
+ width: 56px; height: 56px;
769
+ border: 2px solid var(--vermillion); border-radius: 4px;
770
+ display: flex; align-items: center; justify-content: center;
771
+ font-size: 22px; font-weight: 900;
772
+ color: var(--vermillion);
773
+ margin-bottom: 12px;
774
+ }
775
+ .v-pane-name {
776
+ font-size: 28px; font-weight: 900;
777
+ letter-spacing: 6px; color: var(--ink);
778
+ }
779
+ .v-pane-bio {
780
+ font-size: 16px; line-height: 2.4;
781
+ color: var(--ink-mid);
782
+ padding-left: 16px;
783
+ border-left: 1px solid var(--border);
784
+ }
785
+ .v-pane-p {
786
+ margin-bottom: 0;
787
+ margin-left: 12px;
788
+ }
789
+
790
+ @media (max-width: 768px) {
791
+ .h-content { padding: 30px 20px; }
792
+ }
793
+ </style>