@hanology/cham-browser 0.4.60 → 0.4.61

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanology/cham-browser",
3
- "version": "0.4.60",
3
+ "version": "0.4.61",
4
4
  "description": "CHAM — browser-compatible parser, serializer, and site generator for Classical Han Annotated Markdown",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -52,12 +52,14 @@ function onKey(event: KeyboardEvent) {
52
52
  <template>
53
53
  <div @keydown="onKey">
54
54
  <router-view v-slot="{ Component, route }">
55
- <Suspense :key="route.fullPath">
56
- <component :is="Component" />
57
- <template #fallback>
58
- <div class="route-loading"></div>
59
- </template>
60
- </Suspense>
55
+ <Transition name="page-fade" mode="out-in">
56
+ <Suspense :key="route.fullPath">
57
+ <component :is="Component" />
58
+ <template #fallback>
59
+ <div class="route-loading"></div>
60
+ </template>
61
+ </Suspense>
62
+ </Transition>
61
63
  </router-view>
62
64
  <!-- 橫排模式才顯示浮動設定鈕 -->
63
65
  <ReadingToolbar v-if="!isVertical" />
@@ -81,6 +83,11 @@ function onKey(event: KeyboardEvent) {
81
83
  </template>
82
84
 
83
85
  <style>
86
+ .page-fade-enter-active { transition: opacity 0.15s ease, transform 0.2s var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1)); }
87
+ .page-fade-leave-active { transition: opacity 0.1s ease; }
88
+ .page-fade-enter-from { opacity: 0; transform: translateY(8px); }
89
+ .page-fade-leave-to { opacity: 0; }
90
+
84
91
  .about-overlay {
85
92
  position: fixed; inset: 0;
86
93
  background: rgba(var(--shadow-rgb), 0.3);
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
3
3
  import { annotationToPronSegment } from '../utils/annotationParser'
4
+ import { kindLabel } from '../utils/annotationLabels'
4
5
  import { toChineseNumber } from '../utils/chineseNumber'
5
6
  import PronunciationGroup from './PronunciationGroup.vue'
6
7
  import type { Annotation } from '../types'
@@ -98,24 +99,6 @@ function headword(ann: Annotation): string {
98
99
  return props.headwords[ann.id] || ''
99
100
  }
100
101
 
101
- function kindLabel(ann: Annotation): string {
102
- const map: Record<string, string> = {
103
- pronunciation: '讀音',
104
- semantic: '釋義',
105
- etymology: '詞源',
106
- note: '備注',
107
- definition: '釋義',
108
- commentary: '注',
109
- translation: '譯文',
110
- person: '人名',
111
- place: '地名',
112
- event: '事件',
113
- date: '紀年',
114
- allusion: '典故',
115
- }
116
- return map[ann.kind] || ann.kind
117
- }
118
-
119
102
  function layerLabel(ann: Annotation): string {
120
103
  if (!props.layerLabels || !ann.id) return ''
121
104
  for (const [prefix, label] of Object.entries(props.layerLabels)) {
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
3
3
  import { annotationToPronSegment } from '../utils/annotationParser'
4
+ import { kindLabel } from '../utils/annotationLabels'
4
5
  import { toChineseNumber } from '../utils/chineseNumber'
5
6
  import PronunciationGroup from './PronunciationGroup.vue'
6
7
  import type { Annotation } from '../types'
@@ -48,24 +49,6 @@ function layerLabel(ann: Annotation): string {
48
49
  return ''
49
50
  }
50
51
 
51
- function kindLabel(ann: Annotation): string {
52
- const map: Record<string, string> = {
53
- pronunciation: '讀音',
54
- semantic: '釋義',
55
- etymology: '詞源',
56
- note: '備注',
57
- definition: '釋義',
58
- commentary: '注',
59
- translation: '譯文',
60
- person: '人名',
61
- place: '地名',
62
- event: '事件',
63
- date: '紀年',
64
- allusion: '典故',
65
- }
66
- return map[ann.kind] || ann.kind
67
- }
68
-
69
52
  function dominantKind(): string {
70
53
  if (!props.annotations.length) return ''
71
54
  const counts: Record<string, number> = {}
@@ -13,7 +13,7 @@ function genreLabel(genre: string): string {
13
13
  </script>
14
14
 
15
15
  <template>
16
- <div class="bc-root" @click="router.push(`/${props.book.id}`)">
16
+ <div class="bc-root" role="button" tabindex="0" @click="router.push(`/${props.book.id}`)" @keydown.enter="router.push(`/${props.book.id}`)">
17
17
  <div class="bc-accent"></div>
18
18
  <div class="bc-body">
19
19
  <h2 class="bc-title">{{ props.book.title }}</h2>
@@ -45,7 +45,7 @@ function onTap(event: MouseEvent) {
45
45
  v-for="(_, i) in verses"
46
46
  :key="i"
47
47
  class="h-display-line h-verse-anim"
48
- :style="{ animationDelay: (0.15 + i * 0.08) + 's' }"
48
+ :style="{ animationDelay: Math.min(0.15 + i * 0.08, 1.2) + 's' }"
49
49
  v-html="verseHtml(i)"
50
50
  />
51
51
  </div>
@@ -15,13 +15,13 @@ const preview = computed(() => {
15
15
  </script>
16
16
 
17
17
  <template>
18
- <div class="pc-root" :class="{ 'pc-vertical': vertical }" @click="$emit('click')">
18
+ <div class="pc-root" :class="{ 'pc-vertical': vertical }" role="button" tabindex="0" @click="$emit('click')" @keydown.enter="$emit('click')">
19
19
  <div class="pc-accent"></div>
20
20
  <div class="pc-body">
21
21
  <div class="pc-num">{{ String(poem.num).padStart(3, '0') }}</div>
22
22
  <h3 class="pc-title">{{ poem.title }}</h3>
23
23
  <div class="pc-author">{{ poem.author }}</div>
24
- <p class="pc-preview" style="white-space: pre-line">{{ preview }}</p>
24
+ <p class="pc-preview">{{ preview }}</p>
25
25
  </div>
26
26
  </div>
27
27
  </template>
@@ -78,6 +78,7 @@ const preview = computed(() => {
78
78
  font-size: 13px; color: var(--ink-faint);
79
79
  margin-top: 14px; line-height: 1.7;
80
80
  overflow: hidden;
81
+ white-space: pre-line;
81
82
  }
82
83
 
83
84
  /* ─── 直排卡片:固定寬度,最小高度 ─── */
@@ -48,7 +48,7 @@ function onTap(event: MouseEvent) {
48
48
  v-for="(_, i) in verses"
49
49
  :key="i"
50
50
  class="v-scroll-line v-verse-anim"
51
- :style="{ animationDelay: (0.2 + i * 0.06) + 's' }"
51
+ :style="{ animationDelay: Math.min(0.2 + i * 0.06, 1.0) + 's' }"
52
52
  v-html="verseHtml(i)"
53
53
  />
54
54
  </div>
@@ -68,6 +68,11 @@ const messages: Record<Locale, Record<string, string>> = {
68
68
  'role.editor': '編者',
69
69
  'role.translator': '譯者',
70
70
  'role.annotator': '注者',
71
+ 'section.background': '背景資料',
72
+ 'section.analysis': '賞析重點',
73
+ 'section.preparation': '預習活動',
74
+ 'section.follow_up': '跟進活動',
75
+ 'section.think_questions': '想一想',
71
76
  },
72
77
  'zh-Hans': {
73
78
  'site.title': '古典诗文图书馆',
@@ -128,6 +133,11 @@ const messages: Record<Locale, Record<string, string>> = {
128
133
  'role.editor': '编者',
129
134
  'role.translator': '译者',
130
135
  'role.annotator': '注者',
136
+ 'section.background': '背景资料',
137
+ 'section.analysis': '赏析重点',
138
+ 'section.preparation': '预习活动',
139
+ 'section.follow_up': '跟进活动',
140
+ 'section.think_questions': '想一想',
131
141
  },
132
142
  'en': {
133
143
  'site.title': 'Classical Chinese Text Library',
@@ -188,6 +198,11 @@ const messages: Record<Locale, Record<string, string>> = {
188
198
  'role.editor': 'Editor',
189
199
  'role.translator': 'Translator',
190
200
  'role.annotator': 'Annotator',
201
+ 'section.background': 'Background',
202
+ 'section.analysis': 'Analysis',
203
+ 'section.preparation': 'Pre-reading',
204
+ 'section.follow_up': 'Follow-up',
205
+ 'section.think_questions': 'Think About It',
191
206
  },
192
207
  }
193
208
 
@@ -138,7 +138,6 @@ body {
138
138
  ::-webkit-scrollbar-track { background: transparent; }
139
139
  ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
140
140
  ::-webkit-scrollbar-thumb:hover { background: var(--ink-faint); }
141
- html[dir="rtl"] ::-webkit-scrollbar-thumb { background: var(--gold); }
142
141
 
143
142
  a { color: inherit; text-decoration: none; }
144
143
  button { font-family: inherit; }
@@ -0,0 +1,20 @@
1
+ import type { Annotation } from '../types'
2
+
3
+ const KIND_LABELS: Record<string, string> = {
4
+ pronunciation: '讀音',
5
+ semantic: '釋義',
6
+ etymology: '詞源',
7
+ note: '備注',
8
+ definition: '釋義',
9
+ commentary: '注',
10
+ translation: '譯文',
11
+ person: '人名',
12
+ place: '地名',
13
+ event: '事件',
14
+ date: '紀年',
15
+ allusion: '典故',
16
+ }
17
+
18
+ export function kindLabel(ann: Annotation): string {
19
+ return KIND_LABELS[ann.kind] || ann.kind
20
+ }
@@ -7,11 +7,14 @@ import { useData } from '../composables/useData'
7
7
  import { useTitle } from '../composables/useTitle'
8
8
  import { useReadingMode } from '../composables/useReadingMode'
9
9
  import { useHorizontalScroll } from '../composables/useHorizontalScroll'
10
+ import { useI18n } from '../composables/useI18n'
10
11
  import SideNav from '../components/SideNav.vue'
12
+ import BackToTop from '../components/BackToTop.vue'
11
13
  import type { Piece } from '../types'
12
14
 
13
15
  const route = useRoute()
14
16
  const router = useRouter()
17
+ const { t } = useI18n()
15
18
 
16
19
  const { loadLibrary, books, singleBook } = useLibrary()
17
20
  await loadLibrary()
@@ -51,11 +54,11 @@ function goHome() { router.push('/') }
51
54
  <div class="v-seal">{{ authorName.charAt(0) }}</div>
52
55
  <h1 class="v-name">{{ authorName }}</h1>
53
56
  <span v-if="author.era" class="v-era">{{ author.era }}</span>
54
- <span class="v-count">{{ authorPieces.length }} 篇收錄作品</span>
57
+ <span class="v-count">{{ t('author.worksCount', { count: authorPieces.length }) }}</span>
55
58
  </section>
56
59
 
57
60
  <section v-if="author.bio" class="v-bio">
58
- <div class="v-bio-label">作者簡介</div>
61
+ <div class="v-bio-label">{{ t('author.biography') }}</div>
59
62
  <div class="v-bio-text">{{ author.bio }}</div>
60
63
  </section>
61
64
 
@@ -63,7 +66,10 @@ function goHome() { router.push('/') }
63
66
  v-for="piece in authorPieces"
64
67
  :key="`${piece.bookId}-${piece.num}`"
65
68
  class="v-work"
69
+ role="button"
70
+ tabindex="0"
66
71
  @click="openPiece(piece)"
72
+ @keydown.enter="openPiece(piece)"
67
73
  >
68
74
  <div class="v-work-num">{{ String(piece.num).padStart(3, '0') }}</div>
69
75
  <div class="v-work-title">{{ piece.title }}</div>
@@ -76,15 +82,15 @@ function goHome() { router.push('/') }
76
82
  <div class="h-page">
77
83
  <nav class="h-nav">
78
84
  <div class="h-nav-inner">
79
- <button class="h-back" @click="goBack">← 返回</button>
85
+ <button class="h-back" @click="goBack">← {{ t('nav.back') }}</button>
80
86
  <div class="h-breadcrumb">
81
- <span class="h-sep">作者</span>
87
+ <span class="h-sep">{{ t('role.author') }}</span>
82
88
  <span class="h-sep">·</span>
83
89
  <span class="h-author-name">{{ authorName }}</span>
84
90
  </div>
85
91
  <div class="h-controls">
86
- <span class="h-tag">{{ author.era || '未知朝代' }}</span>
87
- <span class="h-tag">{{ authorPieces.length }} 篇</span>
92
+ <span class="h-tag">{{ author.era || t('author.unknownEra') }}</span>
93
+ <span class="h-tag">{{ t('stat.pieceCount', { count: authorPieces.length }) }}</span>
88
94
  </div>
89
95
  </div>
90
96
  </nav>
@@ -96,24 +102,27 @@ function goHome() { router.push('/') }
96
102
  <h1 class="h-name">{{ authorName }}</h1>
97
103
  <div class="h-meta">
98
104
  <span v-if="author.era" class="h-era">{{ author.era }}</span>
99
- <span class="h-count">{{ authorPieces.length }} 篇收錄作品</span>
105
+ <span class="h-count">{{ t('author.worksCount', { count: authorPieces.length }) }}</span>
100
106
  </div>
101
107
  </div>
102
108
  </div>
103
109
 
104
110
  <div v-if="author.bio" class="h-bio">
105
- <h3>作者簡介</h3>
111
+ <h3>{{ t('author.biography') }}</h3>
106
112
  <p>{{ author.bio }}</p>
107
113
  </div>
108
114
 
109
115
  <div class="h-works">
110
- <h3>收錄作品</h3>
116
+ <h3>{{ t('author.collectedWorks') }}</h3>
111
117
  <div class="h-grid">
112
118
  <div
113
119
  v-for="piece in authorPieces"
114
120
  :key="`${piece.bookId}-${piece.num}`"
115
121
  class="h-work"
122
+ role="button"
123
+ tabindex="0"
116
124
  @click="openPiece(piece)"
125
+ @keydown.enter="openPiece(piece)"
117
126
  >
118
127
  <div class="h-work-num">{{ String(piece.num).padStart(3, '0') }}</div>
119
128
  <div class="h-work-title">{{ piece.title }}</div>
@@ -123,11 +132,13 @@ function goHome() { router.push('/') }
123
132
  </div>
124
133
  </div>
125
134
  </div>
135
+
136
+ <BackToTop />
126
137
  </div>
127
138
  </div>
128
139
 
129
- <div v-else style="text-align:center;padding-top:120px">
130
- <p style="font-size:18px;color:var(--ink-faint)">載入中…</p>
140
+ <div v-else class="page-loading">
141
+ <div class="page-loading-seal">文</div>
131
142
  </div>
132
143
  </template>
133
144
 
@@ -78,7 +78,7 @@ function scrollToCatalog() {
78
78
  <span class="v-ch-line"> </span>
79
79
  <span class="v-count">{{ t('catalog.total', { count: filtered.length }) }}</span>
80
80
  <span class="v-search-wrap">
81
- <input v-model="searchQuery" class="v-search" :placeholder="t('catalog.search')" />
81
+ <input v-model="searchQuery" class="v-search" :placeholder="t('catalog.search')" aria-label="search" />
82
82
  </span>
83
83
  </section>
84
84
 
@@ -53,6 +53,12 @@ function bookCategory(book: BookMeta): string {
53
53
  return t('genre.classicalText')
54
54
  }
55
55
 
56
+ function bookCategoryKey(book: BookMeta): string {
57
+ if (book.id.startsWith('skqs-')) return 'fourTreasuries'
58
+ if (book.id === 'primary' || book.id === 'primary-culture' || book.id === 'secondary' || book.id === 'nss') return 'textbooks'
59
+ return 'classicalText'
60
+ }
61
+
56
62
  const groupedBooks = computed(() => {
57
63
  const groups = new Map<string, BookMeta[]>()
58
64
  const order = [t('genre.textbooks'), t('genre.classicalText'), t('genre.fourTreasuries')]
@@ -100,7 +106,7 @@ function openBook(bookId: string) {
100
106
  v-for="(book, bi) in group.books"
101
107
  :key="book.id"
102
108
  class="v-spine v-spine-anim"
103
- :class="'v-spine-' + bookCategory(book).replace(/\s/g, '-')"
109
+ :data-cat="bookCategoryKey(book)"
104
110
  :style="{ animationDelay: bi * 0.04 + 's' }"
105
111
  @click="openBook(book.id)"
106
112
  >
@@ -284,8 +290,8 @@ function openBook(bookId: string) {
284
290
 
285
291
  /* Accent colors per category */
286
292
  .v-spine .v-spine-accent { background: var(--vermillion); }
287
- .v-spine.v-spine-教科書 .v-spine-accent { background: var(--gold); }
288
- .v-spine.v-spine-四庫全書 .v-spine-accent { background: var(--jade); }
293
+ .v-spine[data-cat="textbooks"] .v-spine-accent { background: var(--gold); }
294
+ .v-spine[data-cat="fourTreasuries"] .v-spine-accent { background: var(--jade); }
289
295
 
290
296
  .v-spine-title {
291
297
  writing-mode: vertical-rl;
@@ -302,8 +308,8 @@ function openBook(bookId: string) {
302
308
  .v-spine:hover .v-spine-title {
303
309
  color: var(--vermillion);
304
310
  }
305
- .v-spine.v-spine-教科書:hover .v-spine-title { color: var(--gold); }
306
- .v-spine.v-spine-四庫全書:hover .v-spine-title { color: var(--jade); }
311
+ .v-spine[data-cat="textbooks"]:hover .v-spine-title { color: var(--gold); }
312
+ .v-spine[data-cat="fourTreasuries"]:hover .v-spine-title { color: var(--jade); }
307
313
 
308
314
  .v-spine-badge {
309
315
  writing-mode: horizontal-tb;
@@ -415,11 +421,6 @@ function openBook(bookId: string) {
415
421
  transition: all 0.3s var(--ease-out-expo, ease);
416
422
  position: relative;
417
423
  background: var(--surface);
418
- animation: cardEnter 0.5s var(--ease-out-expo, ease) both;
419
- }
420
- @keyframes cardEnter {
421
- from { opacity: 0; transform: translateY(12px); }
422
- to { opacity: 1; transform: translateY(0); }
423
424
  }
424
425
  .lib-card:hover { border-color: var(--gold); box-shadow: 0 6px 24px rgba(var(--shadow-rgb), 0.1); transform: translateY(-2px); }
425
426
  .lib-card:active { transform: scale(0.98); }
@@ -232,12 +232,12 @@ const totalPartAnnotationCount = computed(() => {
232
232
  return piece.value?.parts?.reduce((sum, p) => sum + p.annotations.length, 0) ?? 0
233
233
  })
234
234
 
235
- const SECTION_META: Record<string, { label: string; special: boolean }> = {
236
- background: { label: '背景資料', special: false },
237
- analysis: { label: '賞析重點', special: false },
238
- preparation: { label: '預習活動', special: true },
239
- follow_up: { label: '跟進活動', special: true },
240
- think_questions: { label: '想一想', special: true },
235
+ const SECTION_META: Record<string, { special: boolean }> = {
236
+ background: { special: false },
237
+ analysis: { special: false },
238
+ preparation: { special: true },
239
+ follow_up: { special: true },
240
+ think_questions: { special: true },
241
241
  }
242
242
 
243
243
  const proseSections = computed(() => {
@@ -245,13 +245,12 @@ const proseSections = computed(() => {
245
245
  if (ss && ss.length > 0) {
246
246
  return ss.filter(s => s.key !== 'author_bio' && s.body)
247
247
  }
248
- // Fallback to legacy sections record
249
248
  const sections = piece.value?.sections || {}
250
249
  const result: { key: string; title: string; body: string; order: number; special: boolean }[] = []
251
- for (const [key, label] of Object.entries({ background: '背景資料', analysis: '賞析重點', preparation: '預習活動', follow_up: '跟進活動', think_questions: '想一想' })) {
250
+ for (const [key, i18nKey] of Object.entries({ background: 'section.background', analysis: 'section.analysis', preparation: 'section.preparation', follow_up: 'section.follow_up', think_questions: 'section.think_questions' })) {
252
251
  if (sections[key]) {
253
252
  const meta = SECTION_META[key]
254
- result.push({ key, title: label, body: sections[key], order: meta ? (key === 'background' ? 1 : key === 'analysis' ? 2 : 3) : 99, special: meta?.special ?? false })
253
+ result.push({ key, title: t(i18nKey), body: sections[key], order: meta ? (key === 'background' ? 1 : key === 'analysis' ? 2 : 3) : 99, special: meta?.special ?? false })
255
254
  }
256
255
  }
257
256
  return result