@hanology/cham-browser 0.3.9 → 0.4.2

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 (30) hide show
  1. package/dist/cli.js +303 -32
  2. package/dist/cli.js.map +1 -1
  3. package/package.json +1 -1
  4. package/template/index.html +4 -8
  5. package/template/src/App.vue +101 -17
  6. package/template/src/components/AnnotationControlBar.vue +119 -49
  7. package/template/src/components/AnnotationTooltip.vue +319 -95
  8. package/template/src/components/BackToTop.vue +4 -0
  9. package/template/src/components/BookCard.vue +10 -11
  10. package/template/src/components/HorizontalDisplay.vue +56 -0
  11. package/template/src/components/PartBlock.vue +9 -0
  12. package/template/src/components/PoemCard.vue +1 -0
  13. package/template/src/components/PronunciationGroup.vue +27 -18
  14. package/template/src/components/ReadingToolbar.vue +20 -0
  15. package/template/src/components/SectionBlock.vue +91 -12
  16. package/template/src/components/SideNav.vue +5 -4
  17. package/template/src/components/VerticalScroll.vue +35 -0
  18. package/template/src/composables/useAnnotationRenderer.ts +57 -25
  19. package/template/src/composables/useData.ts +6 -1
  20. package/template/src/composables/useI18n.ts +36 -3
  21. package/template/src/composables/useReadingMode.ts +9 -4
  22. package/template/src/composables/useSiteConfig.ts +12 -1
  23. package/template/src/router.ts +0 -2
  24. package/template/src/styles/main.css +88 -0
  25. package/template/src/types.ts +12 -4
  26. package/template/src/views/AuthorView.vue +5 -5
  27. package/template/src/views/BookHome.vue +45 -21
  28. package/template/src/views/LibraryHome.vue +39 -41
  29. package/template/src/views/PieceView.vue +436 -71
  30. package/template/src/views/AboutView.vue +0 -191
@@ -7,6 +7,7 @@ import { useReadingMode } from '../composables/useReadingMode'
7
7
  import { useHorizontalScroll } from '../composables/useHorizontalScroll'
8
8
  import { useAnnotationInteraction } from '../composables/useAnnotationInteraction'
9
9
  import { useData } from '../composables/useData'
10
+ import { useI18n } from '../composables/useI18n'
10
11
  import VerticalScroll from '../components/VerticalScroll.vue'
11
12
  import HorizontalDisplay from '../components/HorizontalDisplay.vue'
12
13
  import SectionBlock from '../components/SectionBlock.vue'
@@ -26,6 +27,7 @@ await load(props.bookId)
26
27
  const { layout } = useReadingMode()
27
28
  const vPageRef = ref<HTMLElement | null>(null)
28
29
  const vScroll = useHorizontalScroll(vPageRef)
30
+ const { t } = useI18n()
29
31
 
30
32
  const authorPaneOpen = ref(false)
31
33
  const selectedAuthorId = ref('')
@@ -43,6 +45,24 @@ onMounted(() => {
43
45
  onUnmounted(() => observer.disconnect())
44
46
  })
45
47
 
48
+ // Keyboard navigation
49
+ onMounted(() => {
50
+ function onKey(e: KeyboardEvent) {
51
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
52
+ const adj = adjacent.value
53
+ if (isVertical.value) {
54
+ if (e.key === 'ArrowLeft' && adj.next !== null) { e.preventDefault(); navigate(1) }
55
+ if (e.key === 'ArrowRight' && adj.prev !== null) { e.preventDefault(); navigate(-1) }
56
+ } else {
57
+ if (e.key === 'ArrowRight' && adj.next !== null) { e.preventDefault(); navigate(1) }
58
+ if (e.key === 'ArrowLeft' && adj.prev !== null) { e.preventDefault(); navigate(-1) }
59
+ }
60
+ if (e.key === 'Escape') { e.preventDefault(); goBack() }
61
+ }
62
+ window.addEventListener('keydown', onKey)
63
+ onUnmounted(() => window.removeEventListener('keydown', onKey))
64
+ })
65
+
46
66
  const piece = computed<Piece | undefined>(() => {
47
67
  const n = typeof props.num === 'string' ? parseInt(props.num, 10) : props.num
48
68
  return getPiece(n)
@@ -86,6 +106,7 @@ function initLayers() {
86
106
  }
87
107
 
88
108
  const mergedAnnotations = computed<Annotation[]>(() => {
109
+ if (!annotationsVisible.value) return []
89
110
  if (!hasLayers.value) return piece.value?.annotations || []
90
111
  const result: Annotation[] = []
91
112
  for (const layer of annotationLayers.value) {
@@ -194,6 +215,8 @@ const proseSections = computed(() => {
194
215
  const { getAuthor, loadShared } = useData()
195
216
  await loadShared()
196
217
 
218
+ const CHAM_LOGO_URL = import.meta.env.CHAM_LOGO_URL || ''
219
+
197
220
  const selectedAuthorName = computed(() => {
198
221
  if (!selectedAuthorId.value) return piece.value?.author || ''
199
222
  const c = piece.value?.contributors?.find(x => x.id === selectedAuthorId.value)
@@ -205,6 +228,30 @@ const selectedAuthorBio = computed(() => {
205
228
  return a?.bio || piece.value?.sections?.author_bio || ''
206
229
  })
207
230
 
231
+ const selectedAuthorEra = computed(() => {
232
+ const name = selectedAuthorName.value
233
+ const a = getAuthor(name)
234
+ return a?.era || piece.value?.era || ''
235
+ })
236
+
237
+ const selectedAuthorWorkCount = computed(() => {
238
+ const name = selectedAuthorName.value
239
+ const a = getAuthor(name)
240
+ return a?.workCount || 0
241
+ })
242
+
243
+ const selectedAuthorData = computed(() => {
244
+ const name = selectedAuthorName.value
245
+ return getAuthor(name)
246
+ })
247
+
248
+ const authorLifespan = computed(() => {
249
+ const a = selectedAuthorData.value
250
+ if (!a?.born && !a?.died) return ''
251
+ if (a.born && a.died) return `${a.born}–${a.died}`
252
+ return a.born ? `${a.born}–` : `?–${a.died}`
253
+ })
254
+
208
255
  function openAuthorPane(id?: string) {
209
256
  selectedAuthorId.value = id || piece.value?.authorId || ''
210
257
  authorPaneOpen.value = true
@@ -219,12 +266,20 @@ function navigate(delta: number) {
219
266
  if (target !== null) router.push(`/${props.bookId}/${target}`)
220
267
  }
221
268
 
269
+ const ROLE_LABELS: Record<string, string> = {
270
+ author: t('role.author'),
271
+ commentator: t('role.commentator'),
272
+ editor: t('role.editor'),
273
+ translator: t('role.translator'),
274
+ annotator: t('role.annotator'),
275
+ }
276
+
222
277
  const contributorGroups = computed(() => {
223
278
  const c = piece.value?.contributors
224
279
  if (!c || c.length <= 1) return []
225
280
  const groups = new Map<string, string[]>()
226
281
  for (const x of c) {
227
- const t = x.title || '作者'
282
+ const t = x.title || ROLE_LABELS[x.role] || '作者'
228
283
  if (!groups.has(t)) groups.set(t, [])
229
284
  groups.get(t)!.push(x.name)
230
285
  }
@@ -271,12 +326,12 @@ function tcy(n: number): string {
271
326
  </div>
272
327
  <div class="v-poem-meta">
273
328
  <template v-if="isMultiPart">
274
- <span class="v-meta-item" v-html="tcy(piece.parts!.length) + ' '" />
275
- <span class="v-meta-item" v-html="totalPartAnnotationCount > 0 ? tcy(totalPartAnnotationCount) + ' ' : '無注'" />
329
+ <span class="v-meta-item" v-html="tcy(piece.parts!.length) + ' ' + t('piece.stanzas')" />
330
+ <span class="v-meta-item" v-html="totalPartAnnotationCount > 0 ? tcy(totalPartAnnotationCount) + ' ' + t('piece.notes') : t('piece.noNotes')" />
276
331
  </template>
277
332
  <template v-else>
278
- <span class="v-meta-item" v-html="tcy(piece.verses.length) + ' '" />
279
- <span class="v-meta-item" v-html="totalAnnotationCount > 0 ? tcy(totalAnnotationCount) + ' ' : '無注'" />
333
+ <span class="v-meta-item" v-html="tcy(piece.verses.length) + ' ' + t('piece.stanzas')" />
334
+ <span class="v-meta-item" v-html="totalAnnotationCount > 0 ? tcy(totalAnnotationCount) + ' ' + t('piece.notes') : t('piece.noNotes')" />
280
335
  </template>
281
336
  </div>
282
337
  </section>
@@ -311,7 +366,7 @@ function tcy(n: number): string {
311
366
  <SectionBlock
312
367
  v-if="!isMultiPart && annotationsVisible && piece.sections.annotations"
313
368
  num=""
314
- label="注釋"
369
+ :label="t('annotation.notes')"
315
370
  :special="false"
316
371
  :text="piece.sections.annotations"
317
372
  :is-annotations="true"
@@ -322,7 +377,7 @@ function tcy(n: number): string {
322
377
  <div class="v-layers-inline v-section">
323
378
  <AnnotationControlBar
324
379
  :layers="annotationLayers"
325
- :has-annotations="piece.annotations.length > 0"
380
+ :has-annotations="true"
326
381
  v-model:active-ids="activeLayerIds"
327
382
  v-model:annotations-visible="annotationsVisible"
328
383
  />
@@ -339,16 +394,14 @@ function tcy(n: number): string {
339
394
  class="v-section"
340
395
  />
341
396
  </template>
342
- <SectionBlock
343
- v-else-if="piece.annotations.length > 0"
344
- num=""
345
- label="注釋"
346
- :special="false"
347
- :text="piece.sections.annotations || ''"
348
- :is-annotations="true"
349
- :vertical="true"
350
- class="v-section"
351
- />
397
+ <div v-else-if="piece.annotations.length > 0 || piece.sections.annotations" class="v-layers-inline v-section">
398
+ <AnnotationControlBar
399
+ :layers="annotationLayers"
400
+ :has-annotations="true"
401
+ v-model:active-ids="activeLayerIds"
402
+ v-model:annotations-visible="annotationsVisible"
403
+ />
404
+ </div>
352
405
 
353
406
  <SectionBlock
354
407
  v-for="(sec, idx) in proseSections"
@@ -365,13 +418,13 @@ function tcy(n: number): string {
365
418
  <nav class="v-nav">
366
419
  <button v-if="adjacent.prev !== null" class="v-nav-btn" @click="navigate(-1)">
367
420
  <span class="v-nav-dir">▲</span>
368
- <span class="v-nav-label">上一篇</span>
421
+ <span class="v-nav-label">{{ t('piece.previous') }}</span>
369
422
  <span class="v-nav-title">{{ getPiece(adjacent.prev)?.title }}</span>
370
423
  </button>
371
424
  <div v-else class="v-nav-spacer" />
372
425
  <button v-if="adjacent.next !== null" class="v-nav-btn" @click="navigate(1)">
373
426
  <span class="v-nav-dir">▼</span>
374
- <span class="v-nav-label">下一篇</span>
427
+ <span class="v-nav-label">{{ t('piece.next') }}</span>
375
428
  <span class="v-nav-title">{{ getPiece(adjacent.next)?.title }}</span>
376
429
  </button>
377
430
  </nav>
@@ -382,6 +435,7 @@ function tcy(n: number): string {
382
435
  :annotations="interaction.items"
383
436
  :layer-labels="layerLabels"
384
437
  :style="interaction.style"
438
+ :vertical="true"
385
439
  @close="interaction.dismiss"
386
440
  @tooltip-enter="interaction.onTooltipEnter"
387
441
  @tooltip-leave="interaction.onTooltipLeave"
@@ -394,12 +448,28 @@ function tcy(n: number): string {
394
448
  <button class="v-pane-close" @click="closeAuthorPane">✕</button>
395
449
  <div class="v-pane-header">
396
450
  <div class="v-pane-name">{{ selectedAuthorName }}</div>
451
+ <div class="v-pane-meta">
452
+ <span v-if="selectedAuthorEra">{{ selectedAuthorEra }}</span>
453
+ <span v-if="authorLifespan">{{ authorLifespan }}</span>
454
+ <span v-if="selectedAuthorWorkCount" class="v-pane-count">{{ t('stat.pieceCount', { count: selectedAuthorWorkCount }) }}</span>
455
+ </div>
456
+ <div v-if="selectedAuthorData?.courtesyName || selectedAuthorData?.artName" class="v-pane-names">
457
+ <span v-if="selectedAuthorData?.courtesyName">字{{ selectedAuthorData.courtesyName }}</span>
458
+ <span v-if="selectedAuthorData?.artName">號{{ selectedAuthorData.artName }}</span>
459
+ </div>
460
+ </div>
461
+ <div class="v-pane-links">
462
+ <a v-if="selectedAuthorData?.ctextId" :href="`https://ctext.org/wiki.pl?if=en&res=${selectedAuthorData.ctextId}`" target="_blank" rel="noopener" class="v-pane-link">CTEXT</a>
463
+ <a v-if="selectedAuthorData?.wikipediaZh" :href="selectedAuthorData.wikipediaZh" target="_blank" rel="noopener" class="v-pane-link">維基</a>
464
+ <a v-if="selectedAuthorData?.wikipediaEn" :href="selectedAuthorData.wikipediaEn" target="_blank" rel="noopener" class="v-pane-link">Wikipedia</a>
465
+ <a v-if="selectedAuthorData?.wikidata" :href="`https://www.wikidata.org/wiki/${selectedAuthorData.wikidata}`" target="_blank" rel="noopener" class="v-pane-link">Wikidata</a>
397
466
  </div>
398
467
  <div v-if="selectedAuthorBio" class="v-pane-bio">
399
468
  <div v-for="p in selectedAuthorBio.split('\n').filter(l => l.trim())" :key="p" class="v-pane-p">
400
469
  {{ p.trim() }}
401
470
  </div>
402
471
  </div>
472
+ <div v-if="!selectedAuthorBio" class="v-pane-empty">{{ t('piece.noAuthorData') }}</div>
403
473
  </div>
404
474
  </div>
405
475
  </Transition>
@@ -412,14 +482,16 @@ function tcy(n: number): string {
412
482
  <div class="h-page">
413
483
  <nav class="h-nav">
414
484
  <div class="h-nav-inner">
415
- <button class="h-back" @click="goBack">← 返回</button>
416
- <div class="h-breadcrumb">
417
- <span v-if="piece.source?.textRef" class="h-source-link" @click="router.push(`/${piece.source.textRef}`)">
418
- {{ meta?.title }} →
485
+ <button class="h-back" @click="goBack">← {{ t('nav.back') }}</button>
486
+ <div class="h-nav-title-row">
487
+ <span v-if="piece.era" class="h-era">{{ piece.era }}</span>
488
+ <span class="h-breadcrumb">
489
+ <span v-if="piece.source?.textRef" class="h-source-link" @click="router.push(`/${piece.source.textRef}`)">
490
+ {{ meta?.title }} →
491
+ </span>
492
+ <span class="h-sep">{{ piece.num }}.</span>
493
+ {{ piece.title }}
419
494
  </span>
420
- <span class="h-sep">{{ piece.num }}.</span>
421
- {{ piece.title }}
422
- <span class="h-sep">·</span>
423
495
  <template v-if="piece.contributors && piece.contributors.length > 1">
424
496
  <template v-for="(group, gi) in contributorGroups" :key="group.title">
425
497
  <span v-if="gi > 0" class="h-sep">|</span>
@@ -430,14 +502,17 @@ function tcy(n: number): string {
430
502
  <span v-else class="h-author-link" @click="openAuthorPane">{{ piece.author }}</span>
431
503
  </div>
432
504
  <div class="h-controls">
505
+ <span class="h-tag h-tag-pager">{{ piece.num }} / {{ pieces.length }}</span>
433
506
  <template v-if="isMultiPart">
434
- <span class="h-tag">{{ piece.parts!.length }} 段</span>
435
- <span class="h-tag">{{ totalPartAnnotationCount > 0 ? totalPartAnnotationCount + ' ' : '無注' }}</span>
507
+ <span class="h-tag">{{ piece.parts!.length }} {{ t('piece.stanzas') }}</span>
508
+ <span class="h-tag">{{ totalPartAnnotationCount > 0 ? totalPartAnnotationCount + ' ' + t('piece.notes') : t('piece.noNotes') }}</span>
436
509
  </template>
437
510
  <template v-else>
438
- <span class="h-tag">{{ piece.verses.length }} 段</span>
439
- <span class="h-tag">{{ totalAnnotationCount > 0 ? totalAnnotationCount + ' ' : '無注' }}</span>
511
+ <span class="h-tag">{{ piece.verses.length }} {{ t('piece.stanzas') }}</span>
512
+ <span class="h-tag">{{ totalAnnotationCount > 0 ? totalAnnotationCount + ' ' + t('piece.notes') : t('piece.noNotes') }}</span>
440
513
  </template>
514
+ <button v-if="adjacent.prev !== null" class="h-nav-arrow" @click="navigate(-1)" :title="t('piece.previous')">←</button>
515
+ <button v-if="adjacent.next !== null" class="h-nav-arrow" @click="navigate(1)" :title="t('piece.next')">→</button>
441
516
  </div>
442
517
  </div>
443
518
  </nav>
@@ -468,10 +543,10 @@ function tcy(n: number): string {
468
543
  </div>
469
544
 
470
545
  <div class="h-sections">
471
- <div v-if="(piece.sections.annotations && annotationsVisible) || hasLayers" class="h-ann-section">
546
+ <div v-if="piece.annotations.length > 0 || piece.sections.annotations || hasLayers" class="h-ann-section">
472
547
  <AnnotationControlBar
473
548
  :layers="annotationLayers"
474
- :has-annotations="piece.annotations.length > 0"
549
+ :has-annotations="true"
475
550
  v-model:active-ids="activeLayerIds"
476
551
  v-model:annotations-visible="annotationsVisible"
477
552
  style="margin-bottom: 16px"
@@ -479,12 +554,12 @@ function tcy(n: number): string {
479
554
  <SectionBlock
480
555
  v-if="annotationsVisible && piece.sections.annotations"
481
556
  num=""
482
- label="注釋"
557
+ :label="t('annotation.notes')"
483
558
  :special="false"
484
559
  :text="piece.sections.annotations"
485
560
  :is-annotations="true"
486
561
  />
487
- <template v-if="hasLayers">
562
+ <template v-if="hasLayers && annotationsVisible">
488
563
  <SectionBlock
489
564
  v-for="block in layerAnnotationBlocks"
490
565
  :key="block.label"
@@ -511,12 +586,12 @@ function tcy(n: number): string {
511
586
 
512
587
  <div class="h-nav-bottom">
513
588
  <button v-if="adjacent.prev !== null" class="h-nav-btn" @click="navigate(-1)">
514
- <div class="h-nav-label">← 上一篇</div>
589
+ <div class="h-nav-label">← {{ t('piece.previous') }}</div>
515
590
  <div class="h-nav-title">{{ getPiece(adjacent.prev)?.title }}</div>
516
591
  </button>
517
592
  <div v-else />
518
593
  <button v-if="adjacent.next !== null" class="h-nav-btn h-nav-next" @click="navigate(1)">
519
- <div class="h-nav-label">下一篇 →</div>
594
+ <div class="h-nav-label">{{ t('piece.next') }} →</div>
520
595
  <div class="h-nav-title">{{ getPiece(adjacent.next)?.title }}</div>
521
596
  </button>
522
597
  </div>
@@ -528,6 +603,7 @@ function tcy(n: number): string {
528
603
  :annotations="interaction.items"
529
604
  :layer-labels="layerLabels"
530
605
  :style="interaction.style"
606
+ :vertical="false"
531
607
  @close="interaction.dismiss"
532
608
  @tooltip-enter="interaction.onTooltipEnter"
533
609
  @tooltip-leave="interaction.onTooltipLeave"
@@ -543,14 +619,37 @@ function tcy(n: number): string {
543
619
  <div class="h-pane-header">
544
620
  <div>
545
621
  <div class="h-pane-name">{{ selectedAuthorName }}</div>
546
- <div class="h-pane-meta">{{ piece.title }} 等</div>
622
+ <div class="h-pane-meta">
623
+ <span v-if="selectedAuthorEra" class="h-pane-era">{{ selectedAuthorEra }}</span>
624
+ <span v-if="authorLifespan" class="h-pane-lifespan">{{ authorLifespan }}</span>
625
+ <span v-if="selectedAuthorWorkCount" class="h-pane-count">{{ t('piece.collected', { count: selectedAuthorWorkCount }) }}</span>
626
+ </div>
627
+ <div v-if="selectedAuthorData?.courtesyName || selectedAuthorData?.artName" class="h-pane-alt-names">
628
+ <span v-if="selectedAuthorData?.courtesyName">字 {{ selectedAuthorData.courtesyName }}</span>
629
+ <span v-if="selectedAuthorData?.artName">號 {{ selectedAuthorData.artName }}</span>
630
+ </div>
547
631
  </div>
548
632
  </div>
633
+ <div class="h-pane-links">
634
+ <a v-if="selectedAuthorData?.ctextId" :href="`https://ctext.org/wiki.pl?if=en&res=${selectedAuthorData.ctextId}`" target="_blank" rel="noopener" class="h-pane-link">
635
+ <span class="link-icon">文</span> CTEXT
636
+ </a>
637
+ <a v-if="selectedAuthorData?.wikipediaZh" :href="selectedAuthorData.wikipediaZh" target="_blank" rel="noopener" class="h-pane-link">
638
+ <span class="link-icon">維</span> 維基百科
639
+ </a>
640
+ <a v-if="selectedAuthorData?.wikipediaEn" :href="selectedAuthorData.wikipediaEn" target="_blank" rel="noopener" class="h-pane-link">
641
+ <span class="link-icon">W</span> Wikipedia
642
+ </a>
643
+ <a v-if="selectedAuthorData?.wikidata" :href="`https://www.wikidata.org/wiki/${selectedAuthorData.wikidata}`" target="_blank" rel="noopener" class="h-pane-link">
644
+ <span class="link-icon">Q</span> Wikidata
645
+ </a>
646
+ </div>
549
647
  <div v-if="selectedAuthorBio" class="h-pane-bio">
550
648
  <div v-for="p in selectedAuthorBio.split('\n').filter(l => l.trim())" :key="p" class="h-pane-p">
551
649
  {{ p.trim() }}
552
650
  </div>
553
651
  </div>
652
+ <div v-if="!selectedAuthorBio" class="h-pane-empty">暫無作者資料</div>
554
653
  </div>
555
654
  </div>
556
655
  </Transition>
@@ -558,8 +657,9 @@ function tcy(n: number): string {
558
657
  </div>
559
658
  </div>
560
659
 
561
- <div v-else class="loading">
562
- <div class="loading-seal">詩</div>
660
+ <div v-else class="page-loading">
661
+ <img v-if="CHAM_LOGO_URL" :src="CHAM_LOGO_URL" alt="" class="page-loading-logo" />
662
+ <div v-else class="page-loading-seal">文</div>
563
663
  </div>
564
664
  </template>
565
665
 
@@ -567,20 +667,9 @@ function tcy(n: number): string {
567
667
  /* ═══════ 直排模式 ═══════ */
568
668
 
569
669
  .v-page {
570
- height: 100vh;
571
- display: flex;
572
- flex-direction: row-reverse;
573
- overflow-x: auto;
574
- overflow-y: hidden;
575
- margin-right: var(--nav-width, 56px);
576
670
  padding: 0;
577
671
  background: var(--paper);
578
- scrollbar-width: thin;
579
- scrollbar-color: var(--gold) transparent;
580
- scroll-snap-type: x proximity;
581
672
  }
582
- .v-page::-webkit-scrollbar { height: 4px; }
583
- .v-page::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
584
673
 
585
674
  .v-title-col {
586
675
  writing-mode: vertical-rl;
@@ -739,7 +828,39 @@ function tcy(n: number): string {
739
828
  transition: all 0.2s; white-space: nowrap;
740
829
  }
741
830
  .h-back:hover { background: var(--ink); color: var(--paper); border-color: var(--ink); }
742
- .h-breadcrumb { font-size: 15px; font-weight: 600; letter-spacing: 1px; }
831
+ .h-back:active { transform: scale(0.97); }
832
+
833
+ .h-nav-title-row {
834
+ display: flex;
835
+ align-items: baseline;
836
+ gap: 8px;
837
+ font-size: 15px;
838
+ font-weight: 600;
839
+ letter-spacing: 1px;
840
+ min-width: 0;
841
+ }
842
+
843
+ .h-era {
844
+ display: inline-flex;
845
+ align-items: center;
846
+ padding: 2px 8px;
847
+ background: var(--vermillion);
848
+ color: #fff;
849
+ font-family: var(--sans);
850
+ font-size: 11px;
851
+ font-weight: 700;
852
+ letter-spacing: 1px;
853
+ border-radius: 3px;
854
+ white-space: nowrap;
855
+ flex-shrink: 0;
856
+ }
857
+
858
+ .h-breadcrumb {
859
+ overflow: hidden;
860
+ text-overflow: ellipsis;
861
+ white-space: nowrap;
862
+ min-width: 0;
863
+ }
743
864
  .h-sep { color: var(--ink-faint); font-weight: 300; margin: 0 8px; }
744
865
  .h-author-link {
745
866
  color: var(--ink-light); font-weight: 400;
@@ -757,6 +878,29 @@ function tcy(n: number): string {
757
878
  border-radius: 2px; font-family: var(--sans);
758
879
  font-size: 12px; color: var(--ink-light); letter-spacing: 1px;
759
880
  }
881
+ .h-tag-pager {
882
+ background: var(--surface-warm);
883
+ font-weight: 600;
884
+ color: var(--ink-mid);
885
+ }
886
+
887
+ .h-nav-arrow {
888
+ width: 32px; height: 32px;
889
+ border: 1px solid var(--border);
890
+ border-radius: 4px;
891
+ background: none;
892
+ font-family: var(--sans);
893
+ font-size: 16px;
894
+ color: var(--ink-light);
895
+ cursor: pointer;
896
+ display: flex; align-items: center; justify-content: center;
897
+ transition: all 0.15s;
898
+ }
899
+ .h-nav-arrow:hover {
900
+ border-color: var(--vermillion);
901
+ color: var(--vermillion);
902
+ }
903
+ .h-nav-arrow:active { transform: scale(0.95); }
760
904
 
761
905
  .h-content {
762
906
  max-width: 1200px; margin: 0 auto; padding: 60px 40px;
@@ -781,6 +925,10 @@ function tcy(n: number): string {
781
925
 
782
926
  .h-ann-section {
783
927
  margin-bottom: 16px;
928
+ padding: 20px;
929
+ background: var(--surface);
930
+ border: 1px solid var(--border-light);
931
+ border-radius: 8px;
784
932
  }
785
933
 
786
934
  .h-layers-inline {
@@ -825,6 +973,7 @@ function tcy(n: number): string {
825
973
  transform: translateY(-2px);
826
974
  }
827
975
  .h-nav-btn:hover::after { transform: scaleX(1); }
976
+ .h-nav-btn:active { transform: scale(0.98); }
828
977
  .h-nav-btn.h-nav-next { text-align: right; }
829
978
  .h-nav-label { font-size: 11px; color: var(--ink-faint); font-family: var(--sans); letter-spacing: 2px; margin-bottom: 4px; }
830
979
  .h-nav-title { font-size: 16px; font-weight: 600; letter-spacing: 1px; color: var(--ink); }
@@ -876,7 +1025,83 @@ function tcy(n: number): string {
876
1025
  color: var(--vermillion); flex-shrink: 0;
877
1026
  }
878
1027
  .h-pane-name { font-size: 28px; font-weight: 900; letter-spacing: 4px; color: var(--ink); }
879
- .h-pane-meta { font-size: 14px; color: var(--ink-faint); letter-spacing: 2px; margin-top: 4px; }
1028
+ .h-pane-meta { font-size: 14px; color: var(--ink-faint); letter-spacing: 2px; margin-top: 6px; display: flex; align-items: center; gap: 8px; }
1029
+ .h-pane-era {
1030
+ display: inline-flex;
1031
+ padding: 2px 8px;
1032
+ background: var(--vermillion);
1033
+ color: #fff;
1034
+ font-family: var(--sans);
1035
+ font-size: 11px;
1036
+ font-weight: 700;
1037
+ letter-spacing: 1px;
1038
+ border-radius: 3px;
1039
+ }
1040
+ .h-pane-count {
1041
+ font-family: var(--sans);
1042
+ font-size: 12px;
1043
+ color: var(--ink-faint);
1044
+ letter-spacing: 1px;
1045
+ }
1046
+ .h-pane-lifespan {
1047
+ font-family: var(--sans);
1048
+ font-size: 12px;
1049
+ color: var(--ink-light);
1050
+ letter-spacing: 1px;
1051
+ }
1052
+ .h-pane-alt-names {
1053
+ font-size: 14px;
1054
+ color: var(--ink-light);
1055
+ letter-spacing: 2px;
1056
+ margin-top: 6px;
1057
+ display: flex;
1058
+ gap: 12px;
1059
+ }
1060
+ .h-pane-links {
1061
+ display: flex;
1062
+ gap: 8px;
1063
+ flex-wrap: wrap;
1064
+ margin: 16px 0 0;
1065
+ padding-bottom: 16px;
1066
+ border-bottom: 1px solid var(--border-light);
1067
+ }
1068
+ .h-pane-link {
1069
+ display: inline-flex;
1070
+ align-items: center;
1071
+ gap: 4px;
1072
+ padding: 4px 10px;
1073
+ border: 1px solid var(--border);
1074
+ border-radius: 4px;
1075
+ font-family: var(--sans);
1076
+ font-size: 12px;
1077
+ color: var(--ink-mid);
1078
+ text-decoration: none;
1079
+ letter-spacing: 1px;
1080
+ transition: all 0.15s;
1081
+ }
1082
+ .h-pane-link:hover {
1083
+ border-color: var(--vermillion);
1084
+ color: var(--vermillion);
1085
+ }
1086
+ .h-pane-link .link-icon {
1087
+ display: inline-flex;
1088
+ align-items: center;
1089
+ justify-content: center;
1090
+ width: 18px;
1091
+ height: 18px;
1092
+ border-radius: 3px;
1093
+ background: var(--surface-warm);
1094
+ font-size: 10px;
1095
+ font-weight: 700;
1096
+ }
1097
+ .h-pane-empty {
1098
+ padding: 40px 0;
1099
+ text-align: center;
1100
+ color: var(--ink-faint);
1101
+ font-family: var(--sans);
1102
+ font-size: 14px;
1103
+ letter-spacing: 2px;
1104
+ }
880
1105
  .h-pane-bio { border-top: 1px solid var(--border); padding-top: 24px; }
881
1106
  .h-pane-p {
882
1107
  font-size: 16px; line-height: 2.2;
@@ -931,6 +1156,60 @@ function tcy(n: number): string {
931
1156
  font-size: 28px; font-weight: 900;
932
1157
  letter-spacing: 6px; color: var(--ink);
933
1158
  }
1159
+ .v-pane-meta {
1160
+ font-size: 13px;
1161
+ color: var(--ink-faint);
1162
+ font-family: var(--sans);
1163
+ letter-spacing: 2px;
1164
+ display: flex;
1165
+ gap: 8px;
1166
+ margin-left: 4px;
1167
+ }
1168
+ .v-pane-count {
1169
+ font-size: 12px;
1170
+ color: var(--ink-faint);
1171
+ letter-spacing: 1px;
1172
+ }
1173
+ .v-pane-names {
1174
+ font-size: 14px;
1175
+ color: var(--ink-light);
1176
+ letter-spacing: 2px;
1177
+ display: flex;
1178
+ gap: 8px;
1179
+ margin-left: 4px;
1180
+ }
1181
+ .v-pane-links {
1182
+ display: flex;
1183
+ gap: 8px;
1184
+ padding-left: 16px;
1185
+ border-left: 1px solid var(--border);
1186
+ margin-bottom: 16px;
1187
+ }
1188
+ .v-pane-link {
1189
+ display: inline-flex;
1190
+ align-items: center;
1191
+ padding: 4px 8px;
1192
+ border: 1px solid var(--border);
1193
+ border-radius: 3px;
1194
+ font-family: var(--sans);
1195
+ font-size: 11px;
1196
+ color: var(--ink-mid);
1197
+ text-decoration: none;
1198
+ letter-spacing: 1px;
1199
+ transition: all 0.15s;
1200
+ }
1201
+ .v-pane-link:hover {
1202
+ border-color: var(--vermillion);
1203
+ color: var(--vermillion);
1204
+ }
1205
+ .v-pane-empty {
1206
+ font-size: 14px;
1207
+ color: var(--ink-faint);
1208
+ font-family: var(--sans);
1209
+ letter-spacing: 2px;
1210
+ padding-left: 16px;
1211
+ border-left: 1px solid var(--border);
1212
+ }
934
1213
  .v-pane-bio {
935
1214
  font-size: 16px; line-height: 2.4;
936
1215
  color: var(--ink-mid);
@@ -942,26 +1221,112 @@ function tcy(n: number): string {
942
1221
  margin-left: 12px;
943
1222
  }
944
1223
 
945
- .loading {
946
- display: flex; flex-direction: column;
947
- align-items: center; justify-content: center;
948
- height: 100vh;
949
- }
950
- .loading-seal {
951
- width: 56px; height: 56px;
952
- border: 2px solid var(--vermillion);
953
- border-radius: 4px;
954
- display: flex; align-items: center; justify-content: center;
955
- font-size: 28px; font-weight: 900;
956
- color: var(--vermillion);
957
- animation: pulse 1.2s ease-in-out infinite;
958
- }
959
- @keyframes pulse {
960
- 0%, 100% { opacity: 0.3; }
961
- 50% { opacity: 1; }
962
- }
1224
+
1225
+ /* ─── 觸控回饋 ─── */
1226
+ .v-nav-btn:active { transform: scale(0.97); }
1227
+ .h-back:active { transform: scale(0.97); }
1228
+ .h-source-link:active { opacity: 1; }
1229
+ .v-source-link:active { opacity: 1; }
1230
+ .v-poem-author:active { color: var(--vermillion); }
1231
+ .h-author-link:active { color: var(--vermillion); }
1232
+
1233
+ /* ═══════ 行動裝置適配 ═══════ */
963
1234
 
964
1235
  @media (max-width: 768px) {
965
- .h-content { padding: 30px 20px; }
1236
+ /* ─── 直排模式 ─── */
1237
+ .v-page { margin-right: var(--nav-width, 44px); }
1238
+ .v-title-col { padding: 24px 16px; }
1239
+ .v-poem-title { font-size: 32px; letter-spacing: 8px; }
1240
+ .v-poem-author { font-size: 20px; }
1241
+ .v-poem-col { padding: 16px; }
1242
+
1243
+ /* ─── 橫排模式導航 ─── */
1244
+ .h-nav { padding: 0 16px; }
1245
+ .h-nav-inner {
1246
+ height: auto;
1247
+ min-height: 48px;
1248
+ flex-wrap: wrap;
1249
+ padding: 8px 0;
1250
+ gap: 6px 12px;
1251
+ }
1252
+ .h-back {
1253
+ padding: 6px 10px;
1254
+ font-size: 12px;
1255
+ }
1256
+ .h-nav-title-row {
1257
+ font-size: 14px;
1258
+ order: 3;
1259
+ width: 100%;
1260
+ overflow: hidden;
1261
+ }
1262
+ .h-breadcrumb {
1263
+ overflow: hidden;
1264
+ text-overflow: ellipsis;
1265
+ white-space: nowrap;
1266
+ }
1267
+ .h-era { font-size: 10px; padding: 1px 6px; }
1268
+ .h-sep { margin: 0 4px; }
1269
+ .h-controls {
1270
+ margin-left: 0;
1271
+ gap: 4px;
1272
+ }
1273
+ .h-tag {
1274
+ padding: 3px 8px;
1275
+ font-size: 11px;
1276
+ }
1277
+ .h-nav-arrow { width: 28px; height: 28px; font-size: 14px; }
1278
+
1279
+ /* ─── 橫排內容 ─── */
1280
+ .h-content { padding: 24px 16px; }
1281
+ .h-poem-block { margin-bottom: 40px; }
1282
+
1283
+ .h-multipart {
1284
+ padding: 20px 16px;
1285
+ border-radius: 6px;
1286
+ }
1287
+
1288
+ .h-sections {
1289
+ padding-bottom: 60px;
1290
+ }
1291
+
1292
+ .h-ann-section {
1293
+ margin-bottom: 12px;
1294
+ }
1295
+
1296
+ /* ─── 上/下篇導航 ─── */
1297
+ .h-nav-bottom {
1298
+ gap: 10px;
1299
+ margin: 0 auto 32px;
1300
+ }
1301
+ .h-nav-btn {
1302
+ padding: 16px;
1303
+ border-radius: 6px;
1304
+ }
1305
+ .h-nav-title { font-size: 14px; }
1306
+ .h-nav-label { font-size: 10px; }
1307
+
1308
+ /* ─── 作者面板 ─── */
1309
+ .h-overlay { justify-content: center; align-items: flex-end; }
1310
+ .h-pane {
1311
+ width: 100%;
1312
+ max-height: 85vh;
1313
+ height: auto;
1314
+ border-radius: 16px 16px 0 0;
1315
+ padding: 20px;
1316
+ }
1317
+ .overlay-enter-from .h-pane { transform: translateY(100%); }
1318
+ .overlay-leave-to .h-pane { transform: translateY(40px); }
1319
+
1320
+ .h-pane-name { font-size: 24px; }
1321
+ .h-pane-p { font-size: 15px; line-height: 2; }
1322
+ }
1323
+
1324
+ @media (max-width: 480px) {
1325
+ .h-nav-bottom {
1326
+ grid-template-columns: 1fr;
1327
+ }
1328
+ .h-nav-btn.h-nav-next { text-align: left; }
1329
+ .h-nav-title-row { font-size: 13px; }
1330
+ .h-nav-arrow { display: none; }
966
1331
  }
967
1332
  </style>