@hanology/cham-browser 0.3.8 → 0.4.1

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.
@@ -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 selectedAuthorDynasty = computed(() => {
232
+ const name = selectedAuthorName.value
233
+ const a = getAuthor(name)
234
+ return a?.dynasty || piece.value?.dynasty || ''
235
+ })
236
+
237
+ const selectedAuthorPoemCount = computed(() => {
238
+ const name = selectedAuthorName.value
239
+ const a = getAuthor(name)
240
+ return a?.poemCount || 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>
@@ -394,12 +447,28 @@ function tcy(n: number): string {
394
447
  <button class="v-pane-close" @click="closeAuthorPane">✕</button>
395
448
  <div class="v-pane-header">
396
449
  <div class="v-pane-name">{{ selectedAuthorName }}</div>
450
+ <div class="v-pane-meta">
451
+ <span v-if="selectedAuthorDynasty">{{ selectedAuthorDynasty }}</span>
452
+ <span v-if="authorLifespan">{{ authorLifespan }}</span>
453
+ <span v-if="selectedAuthorPoemCount" class="v-pane-count">{{ t('stat.pieceCount', { count: selectedAuthorPoemCount }) }}</span>
454
+ </div>
455
+ <div v-if="selectedAuthorData?.courtesyName || selectedAuthorData?.artName" class="v-pane-names">
456
+ <span v-if="selectedAuthorData?.courtesyName">字{{ selectedAuthorData.courtesyName }}</span>
457
+ <span v-if="selectedAuthorData?.artName">號{{ selectedAuthorData.artName }}</span>
458
+ </div>
459
+ </div>
460
+ <div class="v-pane-links">
461
+ <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>
462
+ <a v-if="selectedAuthorData?.wikipediaZh" :href="selectedAuthorData.wikipediaZh" target="_blank" rel="noopener" class="v-pane-link">維基</a>
463
+ <a v-if="selectedAuthorData?.wikipediaEn" :href="selectedAuthorData.wikipediaEn" target="_blank" rel="noopener" class="v-pane-link">Wikipedia</a>
464
+ <a v-if="selectedAuthorData?.wikidata" :href="`https://www.wikidata.org/wiki/${selectedAuthorData.wikidata}`" target="_blank" rel="noopener" class="v-pane-link">Wikidata</a>
397
465
  </div>
398
466
  <div v-if="selectedAuthorBio" class="v-pane-bio">
399
467
  <div v-for="p in selectedAuthorBio.split('\n').filter(l => l.trim())" :key="p" class="v-pane-p">
400
468
  {{ p.trim() }}
401
469
  </div>
402
470
  </div>
471
+ <div v-if="!selectedAuthorBio" class="v-pane-empty">{{ t('piece.noAuthorData') }}</div>
403
472
  </div>
404
473
  </div>
405
474
  </Transition>
@@ -412,14 +481,16 @@ function tcy(n: number): string {
412
481
  <div class="h-page">
413
482
  <nav class="h-nav">
414
483
  <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 }} →
484
+ <button class="h-back" @click="goBack">← {{ t('nav.back') }}</button>
485
+ <div class="h-nav-title-row">
486
+ <span v-if="piece.dynasty" class="h-dynasty">{{ piece.dynasty }}</span>
487
+ <span class="h-breadcrumb">
488
+ <span v-if="piece.source?.textRef" class="h-source-link" @click="router.push(`/${piece.source.textRef}`)">
489
+ {{ meta?.title }} →
490
+ </span>
491
+ <span class="h-sep">{{ piece.num }}.</span>
492
+ {{ piece.title }}
419
493
  </span>
420
- <span class="h-sep">{{ piece.num }}.</span>
421
- {{ piece.title }}
422
- <span class="h-sep">·</span>
423
494
  <template v-if="piece.contributors && piece.contributors.length > 1">
424
495
  <template v-for="(group, gi) in contributorGroups" :key="group.title">
425
496
  <span v-if="gi > 0" class="h-sep">|</span>
@@ -430,14 +501,17 @@ function tcy(n: number): string {
430
501
  <span v-else class="h-author-link" @click="openAuthorPane">{{ piece.author }}</span>
431
502
  </div>
432
503
  <div class="h-controls">
504
+ <span class="h-tag h-tag-pager">{{ piece.num }} / {{ pieces.length }}</span>
433
505
  <template v-if="isMultiPart">
434
- <span class="h-tag">{{ piece.parts!.length }} 段</span>
435
- <span class="h-tag">{{ totalPartAnnotationCount > 0 ? totalPartAnnotationCount + ' ' : '無注' }}</span>
506
+ <span class="h-tag">{{ piece.parts!.length }} {{ t('piece.stanzas') }}</span>
507
+ <span class="h-tag">{{ totalPartAnnotationCount > 0 ? totalPartAnnotationCount + ' ' + t('piece.notes') : t('piece.noNotes') }}</span>
436
508
  </template>
437
509
  <template v-else>
438
- <span class="h-tag">{{ piece.verses.length }} 段</span>
439
- <span class="h-tag">{{ totalAnnotationCount > 0 ? totalAnnotationCount + ' ' : '無注' }}</span>
510
+ <span class="h-tag">{{ piece.verses.length }} {{ t('piece.stanzas') }}</span>
511
+ <span class="h-tag">{{ totalAnnotationCount > 0 ? totalAnnotationCount + ' ' + t('piece.notes') : t('piece.noNotes') }}</span>
440
512
  </template>
513
+ <button v-if="adjacent.prev !== null" class="h-nav-arrow" @click="navigate(-1)" :title="t('piece.previous')">←</button>
514
+ <button v-if="adjacent.next !== null" class="h-nav-arrow" @click="navigate(1)" :title="t('piece.next')">→</button>
441
515
  </div>
442
516
  </div>
443
517
  </nav>
@@ -468,10 +542,10 @@ function tcy(n: number): string {
468
542
  </div>
469
543
 
470
544
  <div class="h-sections">
471
- <div v-if="(piece.sections.annotations && annotationsVisible) || hasLayers" class="h-ann-section">
545
+ <div v-if="piece.annotations.length > 0 || piece.sections.annotations || hasLayers" class="h-ann-section">
472
546
  <AnnotationControlBar
473
547
  :layers="annotationLayers"
474
- :has-annotations="piece.annotations.length > 0"
548
+ :has-annotations="true"
475
549
  v-model:active-ids="activeLayerIds"
476
550
  v-model:annotations-visible="annotationsVisible"
477
551
  style="margin-bottom: 16px"
@@ -479,12 +553,12 @@ function tcy(n: number): string {
479
553
  <SectionBlock
480
554
  v-if="annotationsVisible && piece.sections.annotations"
481
555
  num=""
482
- label="注釋"
556
+ :label="t('annotation.notes')"
483
557
  :special="false"
484
558
  :text="piece.sections.annotations"
485
559
  :is-annotations="true"
486
560
  />
487
- <template v-if="hasLayers">
561
+ <template v-if="hasLayers && annotationsVisible">
488
562
  <SectionBlock
489
563
  v-for="block in layerAnnotationBlocks"
490
564
  :key="block.label"
@@ -511,12 +585,12 @@ function tcy(n: number): string {
511
585
 
512
586
  <div class="h-nav-bottom">
513
587
  <button v-if="adjacent.prev !== null" class="h-nav-btn" @click="navigate(-1)">
514
- <div class="h-nav-label">← 上一篇</div>
588
+ <div class="h-nav-label">← {{ t('piece.previous') }}</div>
515
589
  <div class="h-nav-title">{{ getPiece(adjacent.prev)?.title }}</div>
516
590
  </button>
517
591
  <div v-else />
518
592
  <button v-if="adjacent.next !== null" class="h-nav-btn h-nav-next" @click="navigate(1)">
519
- <div class="h-nav-label">下一篇 →</div>
593
+ <div class="h-nav-label">{{ t('piece.next') }} →</div>
520
594
  <div class="h-nav-title">{{ getPiece(adjacent.next)?.title }}</div>
521
595
  </button>
522
596
  </div>
@@ -543,14 +617,37 @@ function tcy(n: number): string {
543
617
  <div class="h-pane-header">
544
618
  <div>
545
619
  <div class="h-pane-name">{{ selectedAuthorName }}</div>
546
- <div class="h-pane-meta">{{ piece.title }} 等</div>
620
+ <div class="h-pane-meta">
621
+ <span v-if="selectedAuthorDynasty" class="h-pane-dynasty">{{ selectedAuthorDynasty }}</span>
622
+ <span v-if="authorLifespan" class="h-pane-lifespan">{{ authorLifespan }}</span>
623
+ <span v-if="selectedAuthorPoemCount" class="h-pane-count">{{ t('piece.collected', { count: selectedAuthorPoemCount }) }}</span>
624
+ </div>
625
+ <div v-if="selectedAuthorData?.courtesyName || selectedAuthorData?.artName" class="h-pane-alt-names">
626
+ <span v-if="selectedAuthorData?.courtesyName">字 {{ selectedAuthorData.courtesyName }}</span>
627
+ <span v-if="selectedAuthorData?.artName">號 {{ selectedAuthorData.artName }}</span>
628
+ </div>
547
629
  </div>
548
630
  </div>
631
+ <div class="h-pane-links">
632
+ <a v-if="selectedAuthorData?.ctextId" :href="`https://ctext.org/wiki.pl?if=en&res=${selectedAuthorData.ctextId}`" target="_blank" rel="noopener" class="h-pane-link">
633
+ <span class="link-icon">文</span> CTEXT
634
+ </a>
635
+ <a v-if="selectedAuthorData?.wikipediaZh" :href="selectedAuthorData.wikipediaZh" target="_blank" rel="noopener" class="h-pane-link">
636
+ <span class="link-icon">維</span> 維基百科
637
+ </a>
638
+ <a v-if="selectedAuthorData?.wikipediaEn" :href="selectedAuthorData.wikipediaEn" target="_blank" rel="noopener" class="h-pane-link">
639
+ <span class="link-icon">W</span> Wikipedia
640
+ </a>
641
+ <a v-if="selectedAuthorData?.wikidata" :href="`https://www.wikidata.org/wiki/${selectedAuthorData.wikidata}`" target="_blank" rel="noopener" class="h-pane-link">
642
+ <span class="link-icon">Q</span> Wikidata
643
+ </a>
644
+ </div>
549
645
  <div v-if="selectedAuthorBio" class="h-pane-bio">
550
646
  <div v-for="p in selectedAuthorBio.split('\n').filter(l => l.trim())" :key="p" class="h-pane-p">
551
647
  {{ p.trim() }}
552
648
  </div>
553
649
  </div>
650
+ <div v-if="!selectedAuthorBio" class="h-pane-empty">暫無作者資料</div>
554
651
  </div>
555
652
  </div>
556
653
  </Transition>
@@ -558,8 +655,9 @@ function tcy(n: number): string {
558
655
  </div>
559
656
  </div>
560
657
 
561
- <div v-else class="loading">
562
- <div class="loading-seal">詩</div>
658
+ <div v-else class="page-loading">
659
+ <img v-if="CHAM_LOGO_URL" :src="CHAM_LOGO_URL" alt="" class="page-loading-logo" />
660
+ <div v-else class="page-loading-seal">文</div>
563
661
  </div>
564
662
  </template>
565
663
 
@@ -567,20 +665,9 @@ function tcy(n: number): string {
567
665
  /* ═══════ 直排模式 ═══════ */
568
666
 
569
667
  .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
668
  padding: 0;
577
669
  background: var(--paper);
578
- scrollbar-width: thin;
579
- scrollbar-color: var(--gold) transparent;
580
- scroll-snap-type: x proximity;
581
670
  }
582
- .v-page::-webkit-scrollbar { height: 4px; }
583
- .v-page::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
584
671
 
585
672
  .v-title-col {
586
673
  writing-mode: vertical-rl;
@@ -739,7 +826,39 @@ function tcy(n: number): string {
739
826
  transition: all 0.2s; white-space: nowrap;
740
827
  }
741
828
  .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; }
829
+ .h-back:active { transform: scale(0.97); }
830
+
831
+ .h-nav-title-row {
832
+ display: flex;
833
+ align-items: baseline;
834
+ gap: 8px;
835
+ font-size: 15px;
836
+ font-weight: 600;
837
+ letter-spacing: 1px;
838
+ min-width: 0;
839
+ }
840
+
841
+ .h-dynasty {
842
+ display: inline-flex;
843
+ align-items: center;
844
+ padding: 2px 8px;
845
+ background: var(--vermillion);
846
+ color: #fff;
847
+ font-family: var(--sans);
848
+ font-size: 11px;
849
+ font-weight: 700;
850
+ letter-spacing: 1px;
851
+ border-radius: 3px;
852
+ white-space: nowrap;
853
+ flex-shrink: 0;
854
+ }
855
+
856
+ .h-breadcrumb {
857
+ overflow: hidden;
858
+ text-overflow: ellipsis;
859
+ white-space: nowrap;
860
+ min-width: 0;
861
+ }
743
862
  .h-sep { color: var(--ink-faint); font-weight: 300; margin: 0 8px; }
744
863
  .h-author-link {
745
864
  color: var(--ink-light); font-weight: 400;
@@ -757,6 +876,29 @@ function tcy(n: number): string {
757
876
  border-radius: 2px; font-family: var(--sans);
758
877
  font-size: 12px; color: var(--ink-light); letter-spacing: 1px;
759
878
  }
879
+ .h-tag-pager {
880
+ background: var(--surface-warm);
881
+ font-weight: 600;
882
+ color: var(--ink-mid);
883
+ }
884
+
885
+ .h-nav-arrow {
886
+ width: 32px; height: 32px;
887
+ border: 1px solid var(--border);
888
+ border-radius: 4px;
889
+ background: none;
890
+ font-family: var(--sans);
891
+ font-size: 16px;
892
+ color: var(--ink-light);
893
+ cursor: pointer;
894
+ display: flex; align-items: center; justify-content: center;
895
+ transition: all 0.15s;
896
+ }
897
+ .h-nav-arrow:hover {
898
+ border-color: var(--vermillion);
899
+ color: var(--vermillion);
900
+ }
901
+ .h-nav-arrow:active { transform: scale(0.95); }
760
902
 
761
903
  .h-content {
762
904
  max-width: 1200px; margin: 0 auto; padding: 60px 40px;
@@ -781,6 +923,10 @@ function tcy(n: number): string {
781
923
 
782
924
  .h-ann-section {
783
925
  margin-bottom: 16px;
926
+ padding: 20px;
927
+ background: var(--surface);
928
+ border: 1px solid var(--border-light);
929
+ border-radius: 8px;
784
930
  }
785
931
 
786
932
  .h-layers-inline {
@@ -825,6 +971,7 @@ function tcy(n: number): string {
825
971
  transform: translateY(-2px);
826
972
  }
827
973
  .h-nav-btn:hover::after { transform: scaleX(1); }
974
+ .h-nav-btn:active { transform: scale(0.98); }
828
975
  .h-nav-btn.h-nav-next { text-align: right; }
829
976
  .h-nav-label { font-size: 11px; color: var(--ink-faint); font-family: var(--sans); letter-spacing: 2px; margin-bottom: 4px; }
830
977
  .h-nav-title { font-size: 16px; font-weight: 600; letter-spacing: 1px; color: var(--ink); }
@@ -876,7 +1023,83 @@ function tcy(n: number): string {
876
1023
  color: var(--vermillion); flex-shrink: 0;
877
1024
  }
878
1025
  .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; }
1026
+ .h-pane-meta { font-size: 14px; color: var(--ink-faint); letter-spacing: 2px; margin-top: 6px; display: flex; align-items: center; gap: 8px; }
1027
+ .h-pane-dynasty {
1028
+ display: inline-flex;
1029
+ padding: 2px 8px;
1030
+ background: var(--vermillion);
1031
+ color: #fff;
1032
+ font-family: var(--sans);
1033
+ font-size: 11px;
1034
+ font-weight: 700;
1035
+ letter-spacing: 1px;
1036
+ border-radius: 3px;
1037
+ }
1038
+ .h-pane-count {
1039
+ font-family: var(--sans);
1040
+ font-size: 12px;
1041
+ color: var(--ink-faint);
1042
+ letter-spacing: 1px;
1043
+ }
1044
+ .h-pane-lifespan {
1045
+ font-family: var(--sans);
1046
+ font-size: 12px;
1047
+ color: var(--ink-light);
1048
+ letter-spacing: 1px;
1049
+ }
1050
+ .h-pane-alt-names {
1051
+ font-size: 14px;
1052
+ color: var(--ink-light);
1053
+ letter-spacing: 2px;
1054
+ margin-top: 6px;
1055
+ display: flex;
1056
+ gap: 12px;
1057
+ }
1058
+ .h-pane-links {
1059
+ display: flex;
1060
+ gap: 8px;
1061
+ flex-wrap: wrap;
1062
+ margin: 16px 0 0;
1063
+ padding-bottom: 16px;
1064
+ border-bottom: 1px solid var(--border-light);
1065
+ }
1066
+ .h-pane-link {
1067
+ display: inline-flex;
1068
+ align-items: center;
1069
+ gap: 4px;
1070
+ padding: 4px 10px;
1071
+ border: 1px solid var(--border);
1072
+ border-radius: 4px;
1073
+ font-family: var(--sans);
1074
+ font-size: 12px;
1075
+ color: var(--ink-mid);
1076
+ text-decoration: none;
1077
+ letter-spacing: 1px;
1078
+ transition: all 0.15s;
1079
+ }
1080
+ .h-pane-link:hover {
1081
+ border-color: var(--vermillion);
1082
+ color: var(--vermillion);
1083
+ }
1084
+ .h-pane-link .link-icon {
1085
+ display: inline-flex;
1086
+ align-items: center;
1087
+ justify-content: center;
1088
+ width: 18px;
1089
+ height: 18px;
1090
+ border-radius: 3px;
1091
+ background: var(--surface-warm);
1092
+ font-size: 10px;
1093
+ font-weight: 700;
1094
+ }
1095
+ .h-pane-empty {
1096
+ padding: 40px 0;
1097
+ text-align: center;
1098
+ color: var(--ink-faint);
1099
+ font-family: var(--sans);
1100
+ font-size: 14px;
1101
+ letter-spacing: 2px;
1102
+ }
880
1103
  .h-pane-bio { border-top: 1px solid var(--border); padding-top: 24px; }
881
1104
  .h-pane-p {
882
1105
  font-size: 16px; line-height: 2.2;
@@ -931,6 +1154,60 @@ function tcy(n: number): string {
931
1154
  font-size: 28px; font-weight: 900;
932
1155
  letter-spacing: 6px; color: var(--ink);
933
1156
  }
1157
+ .v-pane-meta {
1158
+ font-size: 13px;
1159
+ color: var(--ink-faint);
1160
+ font-family: var(--sans);
1161
+ letter-spacing: 2px;
1162
+ display: flex;
1163
+ gap: 8px;
1164
+ margin-left: 4px;
1165
+ }
1166
+ .v-pane-count {
1167
+ font-size: 12px;
1168
+ color: var(--ink-faint);
1169
+ letter-spacing: 1px;
1170
+ }
1171
+ .v-pane-names {
1172
+ font-size: 14px;
1173
+ color: var(--ink-light);
1174
+ letter-spacing: 2px;
1175
+ display: flex;
1176
+ gap: 8px;
1177
+ margin-left: 4px;
1178
+ }
1179
+ .v-pane-links {
1180
+ display: flex;
1181
+ gap: 8px;
1182
+ padding-left: 16px;
1183
+ border-left: 1px solid var(--border);
1184
+ margin-bottom: 16px;
1185
+ }
1186
+ .v-pane-link {
1187
+ display: inline-flex;
1188
+ align-items: center;
1189
+ padding: 4px 8px;
1190
+ border: 1px solid var(--border);
1191
+ border-radius: 3px;
1192
+ font-family: var(--sans);
1193
+ font-size: 11px;
1194
+ color: var(--ink-mid);
1195
+ text-decoration: none;
1196
+ letter-spacing: 1px;
1197
+ transition: all 0.15s;
1198
+ }
1199
+ .v-pane-link:hover {
1200
+ border-color: var(--vermillion);
1201
+ color: var(--vermillion);
1202
+ }
1203
+ .v-pane-empty {
1204
+ font-size: 14px;
1205
+ color: var(--ink-faint);
1206
+ font-family: var(--sans);
1207
+ letter-spacing: 2px;
1208
+ padding-left: 16px;
1209
+ border-left: 1px solid var(--border);
1210
+ }
934
1211
  .v-pane-bio {
935
1212
  font-size: 16px; line-height: 2.4;
936
1213
  color: var(--ink-mid);
@@ -942,26 +1219,112 @@ function tcy(n: number): string {
942
1219
  margin-left: 12px;
943
1220
  }
944
1221
 
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
- }
1222
+
1223
+ /* ─── 觸控回饋 ─── */
1224
+ .v-nav-btn:active { transform: scale(0.97); }
1225
+ .h-back:active { transform: scale(0.97); }
1226
+ .h-source-link:active { opacity: 1; }
1227
+ .v-source-link:active { opacity: 1; }
1228
+ .v-poem-author:active { color: var(--vermillion); }
1229
+ .h-author-link:active { color: var(--vermillion); }
1230
+
1231
+ /* ═══════ 行動裝置適配 ═══════ */
963
1232
 
964
1233
  @media (max-width: 768px) {
965
- .h-content { padding: 30px 20px; }
1234
+ /* ─── 直排模式 ─── */
1235
+ .v-page { margin-right: var(--nav-width, 44px); }
1236
+ .v-title-col { padding: 24px 16px; }
1237
+ .v-poem-title { font-size: 32px; letter-spacing: 8px; }
1238
+ .v-poem-author { font-size: 20px; }
1239
+ .v-poem-col { padding: 16px; }
1240
+
1241
+ /* ─── 橫排模式導航 ─── */
1242
+ .h-nav { padding: 0 16px; }
1243
+ .h-nav-inner {
1244
+ height: auto;
1245
+ min-height: 48px;
1246
+ flex-wrap: wrap;
1247
+ padding: 8px 0;
1248
+ gap: 6px 12px;
1249
+ }
1250
+ .h-back {
1251
+ padding: 6px 10px;
1252
+ font-size: 12px;
1253
+ }
1254
+ .h-nav-title-row {
1255
+ font-size: 14px;
1256
+ order: 3;
1257
+ width: 100%;
1258
+ overflow: hidden;
1259
+ }
1260
+ .h-breadcrumb {
1261
+ overflow: hidden;
1262
+ text-overflow: ellipsis;
1263
+ white-space: nowrap;
1264
+ }
1265
+ .h-dynasty { font-size: 10px; padding: 1px 6px; }
1266
+ .h-sep { margin: 0 4px; }
1267
+ .h-controls {
1268
+ margin-left: 0;
1269
+ gap: 4px;
1270
+ }
1271
+ .h-tag {
1272
+ padding: 3px 8px;
1273
+ font-size: 11px;
1274
+ }
1275
+ .h-nav-arrow { width: 28px; height: 28px; font-size: 14px; }
1276
+
1277
+ /* ─── 橫排內容 ─── */
1278
+ .h-content { padding: 24px 16px; }
1279
+ .h-poem-block { margin-bottom: 40px; }
1280
+
1281
+ .h-multipart {
1282
+ padding: 20px 16px;
1283
+ border-radius: 6px;
1284
+ }
1285
+
1286
+ .h-sections {
1287
+ padding-bottom: 60px;
1288
+ }
1289
+
1290
+ .h-ann-section {
1291
+ margin-bottom: 12px;
1292
+ }
1293
+
1294
+ /* ─── 上/下篇導航 ─── */
1295
+ .h-nav-bottom {
1296
+ gap: 10px;
1297
+ margin: 0 auto 32px;
1298
+ }
1299
+ .h-nav-btn {
1300
+ padding: 16px;
1301
+ border-radius: 6px;
1302
+ }
1303
+ .h-nav-title { font-size: 14px; }
1304
+ .h-nav-label { font-size: 10px; }
1305
+
1306
+ /* ─── 作者面板 ─── */
1307
+ .h-overlay { justify-content: center; align-items: flex-end; }
1308
+ .h-pane {
1309
+ width: 100%;
1310
+ max-height: 85vh;
1311
+ height: auto;
1312
+ border-radius: 16px 16px 0 0;
1313
+ padding: 20px;
1314
+ }
1315
+ .overlay-enter-from .h-pane { transform: translateY(100%); }
1316
+ .overlay-leave-to .h-pane { transform: translateY(40px); }
1317
+
1318
+ .h-pane-name { font-size: 24px; }
1319
+ .h-pane-p { font-size: 15px; line-height: 2; }
1320
+ }
1321
+
1322
+ @media (max-width: 480px) {
1323
+ .h-nav-bottom {
1324
+ grid-template-columns: 1fr;
1325
+ }
1326
+ .h-nav-btn.h-nav-next { text-align: left; }
1327
+ .h-nav-title-row { font-size: 13px; }
1328
+ .h-nav-arrow { display: none; }
966
1329
  }
967
1330
  </style>