@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
@@ -8,38 +8,47 @@ defineProps<{
8
8
 
9
9
  <template>
10
10
  <span class="pron-group">
11
- <span class="ann-pron" :class="segment.lang === 'yue' ? 'ann-yue' : 'ann-cmn'">
11
+ <span class="pron-badge" :class="segment.lang === 'yue' ? 'pron-yue' : 'pron-cmn'">
12
12
  {{ segment.label }}
13
13
  </span>
14
- <span
15
- v-for="(part, i) in segment.parts"
16
- :key="i"
17
- class="ann-phonetic"
18
- >{{ part }}</span>
14
+ <span class="pron-text">{{ segment.parts.join(' ') }}</span>
19
15
  </span>
20
16
  </template>
21
17
 
22
18
  <style scoped>
23
19
  .pron-group {
24
20
  display: inline-flex;
25
- align-items: baseline;
26
- gap: 0.3em;
21
+ align-items: center;
22
+ gap: 5px;
27
23
  white-space: nowrap;
28
24
  }
29
- .ann-pron {
30
- display: inline-block;
31
- font-size: 0.75em;
25
+ .pron-badge {
26
+ display: inline-flex;
27
+ align-items: center;
28
+ justify-content: center;
29
+ min-width: 22px;
30
+ height: 18px;
31
+ padding: 0 5px;
32
+ border-radius: 9px;
33
+ font-size: 11px;
32
34
  font-family: var(--sans);
33
- font-weight: 600;
34
- padding: 1px 3px;
35
- border-radius: 2px;
36
- vertical-align: middle;
35
+ font-weight: 700;
36
+ letter-spacing: 0.5px;
37
37
  line-height: 1;
38
+ flex-shrink: 0;
39
+ }
40
+ .pron-yue {
41
+ background: var(--jade);
42
+ color: #fff;
43
+ }
44
+ .pron-cmn {
45
+ background: var(--ink);
46
+ color: var(--paper);
38
47
  }
39
- .ann-yue { background: var(--jade); color: #fff; }
40
- .ann-cmn { background: var(--ink); color: var(--paper); }
41
- .ann-phonetic {
48
+ .pron-text {
42
49
  font-family: var(--sans);
50
+ font-size: 13px;
43
51
  color: var(--ink-light);
52
+ letter-spacing: 0.5px;
44
53
  }
45
54
  </style>
@@ -91,6 +91,26 @@ function close() { open.value = false }
91
91
  right: 24px;
92
92
  z-index: 500;
93
93
  }
94
+
95
+ @media (max-width: 768px) {
96
+ .rt { bottom: 16px; right: 16px; }
97
+ .rt-panel {
98
+ position: fixed;
99
+ bottom: 0;
100
+ right: 0;
101
+ left: 0;
102
+ width: auto;
103
+ border-radius: 16px 16px 0 0;
104
+ max-height: 60vh;
105
+ overflow-y: auto;
106
+ overscroll-behavior: contain;
107
+ animation: slideUpMobile 0.3s var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1));
108
+ }
109
+ @keyframes slideUpMobile {
110
+ from { opacity: 0; transform: translateY(100%); }
111
+ to { opacity: 1; transform: translateY(0); }
112
+ }
113
+ }
94
114
  .rt-fab {
95
115
  width: 44px; height: 44px;
96
116
  border-radius: 50%;
@@ -68,14 +68,17 @@ const paragraphsHtml = computed(() => {
68
68
  </div>
69
69
  <div v-if="isAnnotations" class="sb-text sb-ann-list">
70
70
  <div v-for="entry in entries" :key="entry.num" class="sb-ann-entry">
71
- <span class="sb-ann-num">{{ entry.numDisplay }}</span>
72
- <span class="sb-ann-term">{{ entry.term }}</span>
73
- <PronunciationGroup
74
- v-for="seg in entry.pronSegments"
75
- :key="seg.lang"
76
- :segment="seg"
77
- />
78
- <span v-if="entry.definition" class="sb-ann-def">{{ entry.definition }}</span>
71
+ <div class="sb-ann-head">
72
+ <span class="sb-ann-num">{{ entry.numDisplay }}</span>
73
+ <span class="sb-ann-term">{{ entry.term }}</span>
74
+ <PronunciationGroup
75
+ v-for="seg in entry.pronSegments"
76
+ :key="seg.lang"
77
+ :segment="seg"
78
+ class="sb-ann-pron"
79
+ />
80
+ </div>
81
+ <div v-if="entry.definition" class="sb-ann-def">{{ entry.definition }}</div>
79
82
  </div>
80
83
  </div>
81
84
  <div v-else class="sb-text" v-html="paragraphsHtml" />
@@ -114,11 +117,54 @@ const paragraphsHtml = computed(() => {
114
117
  .sb-text :deep(p) { margin-bottom: 16px; text-indent: 2em; }
115
118
  .sb-text :deep(p:last-child) { margin-bottom: 0; }
116
119
 
120
+ /* ─── Annotation list ─── */
117
121
  .sb-ann-list { text-align: start; }
118
- .sb-ann-entry { margin-bottom: 14px; }
119
- .sb-ann-num { color: var(--vermillion); font-weight: 600; font-family: var(--sans); }
120
- .sb-ann-term { font-weight: 600; color: var(--ink); }
121
- .sb-ann-def { white-space: pre-line; }
122
+ .sb-ann-entry {
123
+ padding: 12px 0;
124
+ border-bottom: 1px solid var(--border-light);
125
+ }
126
+ .sb-ann-entry:last-child {
127
+ border-bottom: none;
128
+ padding-bottom: 0;
129
+ }
130
+ .sb-ann-head {
131
+ display: flex;
132
+ flex-wrap: wrap;
133
+ align-items: baseline;
134
+ gap: 6px 10px;
135
+ margin-bottom: 4px;
136
+ }
137
+ .sb-ann-num {
138
+ display: inline-flex;
139
+ align-items: center;
140
+ justify-content: center;
141
+ width: 22px;
142
+ height: 22px;
143
+ border-radius: 4px;
144
+ background: var(--vermillion);
145
+ color: #fff;
146
+ font-family: var(--sans);
147
+ font-size: 12px;
148
+ font-weight: 700;
149
+ flex-shrink: 0;
150
+ }
151
+ .sb-ann-term {
152
+ font-weight: 700;
153
+ font-size: 1.05em;
154
+ color: var(--ink);
155
+ padding: 2px 8px;
156
+ background: var(--surface-warm);
157
+ border-radius: 3px;
158
+ }
159
+ .sb-ann-pron {
160
+ margin-left: 2px;
161
+ }
162
+ .sb-ann-def {
163
+ color: var(--ink-mid);
164
+ line-height: 2;
165
+ white-space: pre-line;
166
+ padding-left: 32px;
167
+ }
122
168
 
123
169
  /* ─── 直排模式 ─── */
124
170
  .sb-vertical {
@@ -163,5 +209,38 @@ const paragraphsHtml = computed(() => {
163
209
  .sb-vertical .sb-ann-entry {
164
210
  margin-bottom: 0;
165
211
  margin-left: 16px;
212
+ padding: 0;
213
+ border-bottom: none;
214
+ }
215
+ .sb-vertical .sb-ann-head {
216
+ flex-direction: column;
217
+ align-items: flex-start;
218
+ gap: 4px;
219
+ }
220
+ .sb-vertical .sb-ann-num {
221
+ width: auto;
222
+ height: auto;
223
+ border-radius: 0;
224
+ background: none;
225
+ color: var(--vermillion);
226
+ font-size: inherit;
227
+ }
228
+ .sb-vertical .sb-ann-term {
229
+ background: none;
230
+ padding: 0;
231
+ font-size: inherit;
232
+ }
233
+ .sb-vertical .sb-ann-def {
234
+ padding-left: 0;
235
+ margin-left: 12px;
236
+ }
237
+
238
+ @media (max-width: 768px) {
239
+ .sb-ann-entry {
240
+ padding: 10px 0;
241
+ }
242
+ .sb-ann-def {
243
+ padding-left: 0;
244
+ }
166
245
  }
167
246
  </style>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { ref } from 'vue'
2
+ import { ref, inject } from 'vue'
3
3
  import { useRouter } from 'vue-router'
4
4
  import { useReadingMode, THEMES, THEME_LABELS, FONT_SIZES } from '../composables/useReadingMode'
5
5
  import type { LayoutMode, FontSize } from '../composables/useReadingMode'
@@ -20,9 +20,10 @@ const emit = defineEmits<{
20
20
 
21
21
  const { theme, layout, mainFontSize, bodyFontSize, setTheme, setLayout, setMainFontSize, setBodyFontSize } = useReadingMode()
22
22
  const { t, setLocale, locale, availableLocales, localeLabels } = useI18n()
23
- const { logoUrl } = useSiteConfig()
23
+ const { logoUrl, aboutHtml } = useSiteConfig()
24
24
  const router = useRouter()
25
25
  const settingsOpen = ref(false)
26
+ const aboutPane = inject<{ toggleAbout: () => void }>('aboutPane')
26
27
 
27
28
  function toggleSettings() { settingsOpen.value = !settingsOpen.value }
28
29
  </script>
@@ -31,7 +32,7 @@ function toggleSettings() { settingsOpen.value = !settingsOpen.value }
31
32
  <nav class="sidenav">
32
33
  <button class="sn-brand" @click="emit('home')" title="首頁">
33
34
  <img v-if="logoUrl" :src="logoUrl" alt="" class="sn-logo" />
34
- <span v-else class="sn-seal">漢流</span>
35
+ <span v-else class="sn-seal">文</span>
35
36
  </button>
36
37
 
37
38
  <button class="sn-btn" @click="emit('back')" title="返回">
@@ -49,7 +50,7 @@ function toggleSettings() { settingsOpen.value = !settingsOpen.value }
49
50
 
50
51
  <div class="sn-spacer" />
51
52
 
52
- <button class="sn-btn" @click="router.push('/about')" title="關於">
53
+ <button v-if="aboutHtml" class="sn-btn" @click="aboutPane?.toggleAbout()" title="關於">
53
54
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
54
55
  </button>
55
56
 
@@ -96,6 +96,11 @@ function onTap(event: MouseEvent) {
96
96
  cursor: help;
97
97
  transition: background 0.15s;
98
98
  }
99
+ :deep(.ann-target.ann-overlap) {
100
+ border-left-width: 3px;
101
+ border-left-style: double;
102
+ padding-left: 3px;
103
+ }
99
104
  :deep(.ann-num) {
100
105
  font-size: 0.45em;
101
106
  font-family: var(--sans);
@@ -121,4 +126,34 @@ function onTap(event: MouseEvent) {
121
126
  :deep(.ann-target.pronunciation.semantic) {
122
127
  border-left-color: var(--gold);
123
128
  }
129
+ :deep(.ann-target.person) {
130
+ border-left-color: var(--ann-person);
131
+ }
132
+ :deep(.ann-target.place) {
133
+ border-left-color: var(--ann-place);
134
+ }
135
+ :deep(.ann-target.event) {
136
+ border-left-color: var(--ann-event);
137
+ }
138
+ :deep(.ann-target.date) {
139
+ border-left-color: var(--ann-date);
140
+ }
141
+ :deep(.ann-target.allusion) {
142
+ border-left-color: var(--ann-allusion);
143
+ }
144
+ :deep(.ann-target.person:hover) {
145
+ background: rgba(58, 90, 140, 0.08);
146
+ }
147
+ :deep(.ann-target.place:hover) {
148
+ background: rgba(139, 105, 20, 0.08);
149
+ }
150
+ :deep(.ann-target.event:hover) {
151
+ background: rgba(107, 76, 138, 0.08);
152
+ }
153
+ :deep(.ann-target.date:hover) {
154
+ background: rgba(42, 122, 122, 0.08);
155
+ }
156
+ :deep(.ann-target.allusion:hover) {
157
+ background: rgba(181, 101, 29, 0.08);
158
+ }
124
159
  </style>
@@ -7,6 +7,7 @@ export interface AnnSpan {
7
7
  start: number
8
8
  end: number
9
9
  annotations: Annotation[]
10
+ overlapping: boolean
10
11
  }
11
12
 
12
13
  export function esc(s: string): string {
@@ -17,27 +18,41 @@ export function buildVerseAnnotations(annotations: Annotation[], verseIndex: num
17
18
  const anns = annotations.filter(a =>
18
19
  a.range.scope === 'verse' && a.range.verseIndex === verseIndex
19
20
  )
20
- const spanMap = new Map<string, Annotation[]>()
21
+ if (!anns.length) return []
22
+
23
+ const points = new Set<number>()
21
24
  for (const a of anns) {
22
- const key = `${a.range.start ?? 0}:${a.range.end ?? 0}`
23
- if (!spanMap.has(key)) spanMap.set(key, [])
24
- spanMap.get(key)!.push(a)
25
+ points.add(a.range.start ?? 0)
26
+ points.add(a.range.end ?? 0)
27
+ }
28
+ const sorted = [...points].sort((a, b) => a - b)
29
+
30
+ const segments: AnnSpan[] = []
31
+ for (let i = 0; i < sorted.length - 1; i++) {
32
+ const start = sorted[i]
33
+ const end = sorted[i + 1]
34
+ const covering = anns.filter(a =>
35
+ (a.range.start ?? 0) <= start && (a.range.end ?? 0) >= end
36
+ )
37
+ if (covering.length === 0) continue
38
+
39
+ const rangeKeys = new Set(
40
+ covering.map(a => `${a.range.start ?? 0}:${a.range.end ?? 0}`)
41
+ )
42
+
43
+ segments.push({
44
+ start,
45
+ end,
46
+ annotations: covering,
47
+ overlapping: rangeKeys.size > 1,
48
+ })
25
49
  }
26
- return Array.from(spanMap.entries()).map(([k, matched]) => {
27
- const [start, end] = k.split(':').map(Number)
28
- return { start, end, annotations: matched }
29
- }).sort((a, b) => a.start - b.start)
50
+
51
+ return segments
30
52
  }
31
53
 
32
54
  export function countVerseSpans(annotations: Annotation[], verseIndex: number): number {
33
- const anns = annotations.filter(a =>
34
- a.range.scope === 'verse' && a.range.verseIndex === verseIndex
35
- )
36
- const keys = new Set<string>()
37
- for (const a of anns) {
38
- keys.add(`${a.range.start ?? 0}:${a.range.end ?? 0}`)
39
- }
40
- return keys.size
55
+ return buildVerseAnnotations(annotations, verseIndex).length
41
56
  }
42
57
 
43
58
  export function renderAnnotatedText(text: string, spans: AnnSpan[], useRuby = false, startNum = 0): string {
@@ -53,13 +68,14 @@ export function renderAnnotatedText(text: string, spans: AnnSpan[], useRuby = fa
53
68
  annCounter++
54
69
  const ids = span.annotations.map(a => a.id).join(',')
55
70
  const kinds = [...new Set(span.annotations.map(a => a.kind))].join(' ')
71
+ const overlapCls = span.overlapping ? ' ann-overlap' : ''
56
72
  const numText = toChineseNumber(annCounter)
57
73
  const body = esc(text.slice(span.start, span.end))
58
74
  if (useRuby) {
59
75
  const rtCls = numText.length > 1 ? 'ann-num ann-num-long' : 'ann-num'
60
- html += `<ruby class="ann-target ${kinds}" data-ann-ids="${ids}">${body}<rp></rp><rt class="${rtCls}">${numText}</rt><rp></rp></ruby>`
76
+ html += `<ruby class="ann-target${overlapCls} ${kinds}" data-ann-ids="${ids}">${body}<rp></rp><rt class="${rtCls}">${numText}</rt><rp></rp></ruby>`
61
77
  } else {
62
- html += `<span class="ann-target ${kinds}" data-ann-ids="${ids}">${body}<sup class="ann-num">${numText}</sup></span>`
78
+ html += `<span class="ann-target${overlapCls} ${kinds}" data-ann-ids="${ids}">${body}<sup class="ann-num">${numText}</sup></span>`
63
79
  }
64
80
  cursor = span.end
65
81
  }
@@ -89,7 +105,8 @@ export function renderVerseGutter(text: string, spans: AnnSpan[], startNum = 0):
89
105
  annCounter++
90
106
  const ids = span.annotations.map(a => a.id).join(',')
91
107
  const kinds = [...new Set(span.annotations.map(a => a.kind))].join(' ')
92
- textHtml += `<span class="ann-target ${kinds}" data-ann-ids="${ids}">${esc(text.slice(span.start, span.end))}</span>`
108
+ const overlapCls = span.overlapping ? ' ann-overlap' : ''
109
+ textHtml += `<span class="ann-target${overlapCls} ${kinds}" data-ann-ids="${ids}">${esc(text.slice(span.start, span.end))}</span>`
93
110
  gutter[span.start] = `<span class="ann-gutter-num ${kinds}" data-ann-ids="${ids}">${toChineseNumber(annCounter)}</span>`
94
111
  cursor = span.end
95
112
  }
@@ -123,11 +140,27 @@ export function useAnnotationTooltip() {
123
140
  const rect = (el ?? event.target as HTMLElement).getBoundingClientRect()
124
141
 
125
142
  if (layout.value === 'vertical') {
126
- const left = rect.left - 12
127
- style.value = {
128
- right: Math.max(8, window.innerWidth - left) + 'px',
129
- top: '50%',
130
- transform: 'translateY(-50%)',
143
+ const isMobile = window.innerWidth < 768
144
+ if (isMobile) {
145
+ style.value = {
146
+ left: '4vw',
147
+ right: '4vw',
148
+ bottom: '0',
149
+ maxWidth: 'none',
150
+ }
151
+ } else if (window.innerWidth >= 1024) {
152
+ style.value = {
153
+ right: '20px',
154
+ top: '72px',
155
+ maxHeight: 'calc(100vh - 100px)',
156
+ }
157
+ } else {
158
+ const right = window.innerWidth - rect.left + 8
159
+ style.value = {
160
+ right: Math.min(right, window.innerWidth - 40) + 'px',
161
+ top: '50%',
162
+ transform: 'translateY(-50%)',
163
+ }
131
164
  }
132
165
  } else {
133
166
  const isMobile = window.innerWidth < 768
@@ -156,7 +189,6 @@ export function useAnnotationTooltip() {
156
189
  const currentIds = items.value.map(a => a.id).sort().join(',')
157
190
  const newIds = annotations.map(a => a.id).sort().join(',')
158
191
  if (currentIds === newIds) {
159
- // Same annotation: dismiss on mobile only (desktop uses hover to manage)
160
192
  if (window.innerWidth < 768) hide()
161
193
  } else {
162
194
  show(event, annotations)
@@ -28,7 +28,12 @@ async function loadShared(): Promise<void> {
28
28
 
29
29
  const authorByName = computed(() => {
30
30
  const map = new Map<string, Author>()
31
- for (const a of authors.value) map.set(a.name, a)
31
+ for (const a of authors.value) {
32
+ const existing = map.get(a.name)
33
+ if (!existing || (!existing.bio && a.bio)) {
34
+ map.set(a.name, a)
35
+ }
36
+ }
32
37
  return map
33
38
  })
34
39
 
@@ -14,6 +14,7 @@ const messages: Record<Locale, Record<string, string>> = {
14
14
  'site.subtitle': 'Classical Chinese Text Library',
15
15
  'nav.back': '返回',
16
16
  'nav.home': '首頁',
17
+ 'nav.about': '關 於',
17
18
  'settings.layout': '版面',
18
19
  'settings.vertical': '直排',
19
20
  'settings.horizontal': '橫排',
@@ -36,7 +37,7 @@ const messages: Record<Locale, Record<string, string>> = {
36
37
  'author.biography': '作者簡介',
37
38
  'author.collectedWorks': '收錄作品',
38
39
  'author.worksCount': '{count} 篇收錄作品',
39
- 'author.unknownDynasty': '未知朝代',
40
+ 'author.unknownEra': '未知朝代',
40
41
  'annotation.pronunciation': '音',
41
42
  'annotation.semantic': '義',
42
43
  'annotation.notes': '注釋',
@@ -52,12 +53,23 @@ const messages: Record<Locale, Record<string, string>> = {
52
53
  'stat.pieceCount': '{count} 篇',
53
54
  'stat.authorCount': '{count} 位作者',
54
55
  'stat.piecePoems': '篇詩文',
56
+ 'stat.authorsLabel': '位作者',
57
+ 'catalog.noResults': '未找到符合「{query}」的篇章',
58
+ 'catalog.enterLibrary': '進 入 文 庫 ↓',
59
+ 'piece.noAuthorData': '暫無作者資料',
60
+ 'piece.collected': '{count} 篇收錄',
61
+ 'role.author': '作者',
62
+ 'role.commentator': '註者',
63
+ 'role.editor': '編者',
64
+ 'role.translator': '譯者',
65
+ 'role.annotator': '注者',
55
66
  },
56
67
  'zh-Hans': {
57
68
  'site.title': '古典诗文图书馆',
58
69
  'site.subtitle': 'Classical Chinese Text Library',
59
70
  'nav.back': '返回',
60
71
  'nav.home': '首页',
72
+ 'nav.about': '关 于',
61
73
  'settings.layout': '版面',
62
74
  'settings.vertical': '直排',
63
75
  'settings.horizontal': '横排',
@@ -80,7 +92,7 @@ const messages: Record<Locale, Record<string, string>> = {
80
92
  'author.biography': '作者简介',
81
93
  'author.collectedWorks': '收录作品',
82
94
  'author.worksCount': '{count} 篇收录作品',
83
- 'author.unknownDynasty': '未知朝代',
95
+ 'author.unknownEra': '未知朝代',
84
96
  'annotation.pronunciation': '音',
85
97
  'annotation.semantic': '义',
86
98
  'annotation.notes': '注释',
@@ -96,12 +108,23 @@ const messages: Record<Locale, Record<string, string>> = {
96
108
  'stat.pieceCount': '{count} 篇',
97
109
  'stat.authorCount': '{count} 位作者',
98
110
  'stat.piecePoems': '篇诗文',
111
+ 'stat.authorsLabel': '位作者',
112
+ 'catalog.noResults': '未找到符合「{query}」的篇章',
113
+ 'catalog.enterLibrary': '进 入 文 库 ↓',
114
+ 'piece.noAuthorData': '暂无作者资料',
115
+ 'piece.collected': '{count} 篇收录',
116
+ 'role.author': '作者',
117
+ 'role.commentator': '注者',
118
+ 'role.editor': '编者',
119
+ 'role.translator': '译者',
120
+ 'role.annotator': '注者',
99
121
  },
100
122
  'en': {
101
123
  'site.title': 'Classical Chinese Text Library',
102
124
  'site.subtitle': 'Classical Chinese Text Library',
103
125
  'nav.back': 'Back',
104
126
  'nav.home': 'Home',
127
+ 'nav.about': 'About',
105
128
  'settings.layout': 'Layout',
106
129
  'settings.vertical': 'Vertical',
107
130
  'settings.horizontal': 'Horizontal',
@@ -124,7 +147,7 @@ const messages: Record<Locale, Record<string, string>> = {
124
147
  'author.biography': 'Biography',
125
148
  'author.collectedWorks': 'Collected Works',
126
149
  'author.worksCount': '{count} collected works',
127
- 'author.unknownDynasty': 'Unknown dynasty',
150
+ 'author.unknownEra': 'Unknown era',
128
151
  'annotation.pronunciation': 'Pron',
129
152
  'annotation.semantic': 'Def',
130
153
  'annotation.notes': 'Notes',
@@ -140,6 +163,16 @@ const messages: Record<Locale, Record<string, string>> = {
140
163
  'stat.pieceCount': '{count} works',
141
164
  'stat.authorCount': '{count} authors',
142
165
  'stat.piecePoems': 'works',
166
+ 'stat.authorsLabel': 'authors',
167
+ 'catalog.noResults': 'No results for "{query}"',
168
+ 'catalog.enterLibrary': 'Enter Library ↓',
169
+ 'piece.noAuthorData': 'No author data available',
170
+ 'piece.collected': '{count} works collected',
171
+ 'role.author': 'Author',
172
+ 'role.commentator': 'Commentator',
173
+ 'role.editor': 'Editor',
174
+ 'role.translator': 'Translator',
175
+ 'role.annotator': 'Annotator',
143
176
  },
144
177
  }
145
178
 
@@ -1,4 +1,4 @@
1
- import { ref, watch } from 'vue'
1
+ import { ref, watch, nextTick } from 'vue'
2
2
 
3
3
  export type Theme = 'light' | 'sepia' | 'dark' | 'oled'
4
4
  export type LayoutMode = 'horizontal' | 'vertical'
@@ -21,18 +21,23 @@ const mainFontSize = ref<FontSize>(24)
21
21
  const bodyFontSize = ref<FontSize>(16)
22
22
 
23
23
  if (!import.meta.env.SSR) {
24
+ // Theme and font sizes only affect CSS, safe to apply before hydration
24
25
  const savedTheme = localStorage.getItem('theme') as Theme | null
25
26
  if (savedTheme && THEMES.includes(savedTheme)) theme.value = savedTheme
26
27
 
27
- const savedLayout = localStorage.getItem('layout') as LayoutMode | null
28
- if (savedLayout === 'vertical' || savedLayout === 'horizontal') layout.value = savedLayout
29
-
30
28
  const savedMain = parseInt(localStorage.getItem('mainFontSize') || '', 10)
31
29
  if (FONT_SIZES.includes(savedMain as any)) mainFontSize.value = savedMain as FontSize
32
30
 
33
31
  const savedBody = parseInt(localStorage.getItem('bodyFontSize') || '', 10)
34
32
  if (FONT_SIZES.includes(savedBody as any)) bodyFontSize.value = savedBody as FontSize
35
33
 
34
+ // Layout controls v-if/v-else DOM structure — must defer to after hydration
35
+ // to avoid SSR/client mismatch (SSR always renders vertical)
36
+ nextTick(() => {
37
+ const savedLayout = localStorage.getItem('layout') as LayoutMode | null
38
+ if (savedLayout === 'vertical' || savedLayout === 'horizontal') layout.value = savedLayout
39
+ })
40
+
36
41
  watch(theme, t => {
37
42
  document.documentElement.setAttribute('data-theme', t)
38
43
  localStorage.setItem('theme', t)
@@ -4,10 +4,16 @@ import type { Theme } from './useReadingMode'
4
4
 
5
5
  const LOGO_LIGHT = import.meta.env.CHAM_LOGO_URL || undefined
6
6
  const LOGO_DARK = import.meta.env.CHAM_LOGO_DARK_URL || undefined
7
+ const SITE_TITLE = import.meta.env.CHAM_SITE_TITLE || ''
8
+ const SITE_SUBTITLE = import.meta.env.CHAM_SITE_SUBTITLE || ''
9
+ const ABOUT_HTML = import.meta.env.CHAM_ABOUT_HTML || ''
7
10
 
8
11
  const DARK_THEMES: Theme[] = ['dark', 'oled']
9
12
 
10
13
  export interface SiteConfig {
14
+ siteTitle: string
15
+ siteSubtitle: string
16
+ aboutHtml: string
11
17
  logoUrl: ReturnType<typeof computed<string | undefined>>
12
18
  }
13
19
 
@@ -17,5 +23,10 @@ export function useSiteConfig(): SiteConfig {
17
23
  if (DARK_THEMES.includes(theme.value) && LOGO_DARK) return LOGO_DARK
18
24
  return LOGO_LIGHT
19
25
  })
20
- return { logoUrl }
26
+ return {
27
+ siteTitle: SITE_TITLE,
28
+ siteSubtitle: SITE_SUBTITLE,
29
+ aboutHtml: ABOUT_HTML,
30
+ logoUrl,
31
+ }
21
32
  }
@@ -8,11 +8,9 @@ import LibraryHome from './views/LibraryHome.vue'
8
8
  import BookHome from './views/BookHome.vue'
9
9
  import PieceView from './views/PieceView.vue'
10
10
  import AuthorView from './views/AuthorView.vue'
11
- import AboutView from './views/AboutView.vue'
12
11
 
13
12
  export const routes: RouteRecordRaw[] = [
14
13
  { path: '/', component: LibraryHome },
15
- { path: '/about', component: AboutView },
16
14
  { path: '/author/:name', component: AuthorView, props: true },
17
15
  { path: '/:bookId', component: BookHome, props: true },
18
16
  { path: '/:bookId/:num', component: PieceView, props: true },