@hanology/cham-browser 0.4.60 → 0.4.62
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 +1 -1
- package/template/src/App.vue +13 -6
- package/template/src/components/AnnotationPane.vue +15 -22
- package/template/src/components/AnnotationTooltip.vue +5 -21
- package/template/src/components/BackToTop.vue +64 -7
- package/template/src/components/BookCard.vue +1 -1
- package/template/src/components/HorizontalDisplay.vue +1 -1
- package/template/src/components/PoemCard.vue +4 -3
- package/template/src/components/ReadingProgress.vue +2 -2
- package/template/src/components/VerticalScroll.vue +1 -1
- package/template/src/composables/useI18n.ts +15 -0
- package/template/src/styles/main.css +12 -1
- package/template/src/utils/annotationLabels.ts +20 -0
- package/template/src/views/AuthorView.vue +28 -11
- package/template/src/views/BookHome.vue +5 -1
- package/template/src/views/LibraryHome.vue +11 -10
- package/template/src/views/PieceView.vue +10 -10
package/package.json
CHANGED
package/template/src/App.vue
CHANGED
|
@@ -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
|
-
<
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
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,7 +1,9 @@
|
|
|
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'
|
|
6
|
+
import { useI18n } from '../composables/useI18n'
|
|
5
7
|
import PronunciationGroup from './PronunciationGroup.vue'
|
|
6
8
|
import type { Annotation } from '../types'
|
|
7
9
|
|
|
@@ -21,6 +23,7 @@ const emit = defineEmits<{
|
|
|
21
23
|
|
|
22
24
|
const bodyRef = ref<HTMLElement | null>(null)
|
|
23
25
|
|
|
26
|
+
const { t } = useI18n()
|
|
24
27
|
const ww = ref(typeof window !== 'undefined' ? window.innerWidth : 1024)
|
|
25
28
|
const isMobile = computed(() => ww.value < 768)
|
|
26
29
|
function onResize() { ww.value = window.innerWidth }
|
|
@@ -98,24 +101,6 @@ function headword(ann: Annotation): string {
|
|
|
98
101
|
return props.headwords[ann.id] || ''
|
|
99
102
|
}
|
|
100
103
|
|
|
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
104
|
function layerLabel(ann: Annotation): string {
|
|
120
105
|
if (!props.layerLabels || !ann.id) return ''
|
|
121
106
|
for (const [prefix, label] of Object.entries(props.layerLabels)) {
|
|
@@ -175,7 +160,7 @@ onBeforeUnmount(() => {
|
|
|
175
160
|
:style="{ width: paneWidth + 'px' }"
|
|
176
161
|
>
|
|
177
162
|
<div class="ann-pane-header">
|
|
178
|
-
<span class="ann-pane-title"
|
|
163
|
+
<span class="ann-pane-title">{{ t('annotation.all') }}</span>
|
|
179
164
|
<span class="ann-pane-count">{{ annotations.length }}</span>
|
|
180
165
|
<button class="ann-pane-close" @click="emit('close')" aria-label="關閉">
|
|
181
166
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
|
@@ -188,7 +173,10 @@ onBeforeUnmount(() => {
|
|
|
188
173
|
:data-ann-id="ann.id"
|
|
189
174
|
class="ann-pane-entry"
|
|
190
175
|
:class="{ active: activeId === ann.id, [ann.kind]: true }"
|
|
176
|
+
role="button"
|
|
177
|
+
tabindex="0"
|
|
191
178
|
@click="emit('select', ann)"
|
|
179
|
+
@keydown.enter="emit('select', ann)"
|
|
192
180
|
>
|
|
193
181
|
<!-- Vertical: headword column on the right side -->
|
|
194
182
|
<div v-if="vertical && headword(ann)" class="ann-pane-v-word">
|
|
@@ -306,6 +294,11 @@ onBeforeUnmount(() => {
|
|
|
306
294
|
background: var(--surface);
|
|
307
295
|
}
|
|
308
296
|
|
|
297
|
+
.ann-pane-entry:focus-visible {
|
|
298
|
+
outline: 2px solid var(--vermillion);
|
|
299
|
+
outline-offset: -2px;
|
|
300
|
+
}
|
|
301
|
+
|
|
309
302
|
.ann-pane-entry.active.pronunciation {
|
|
310
303
|
border-left-color: var(--jade);
|
|
311
304
|
}
|
|
@@ -351,11 +344,11 @@ onBeforeUnmount(() => {
|
|
|
351
344
|
|
|
352
345
|
.ann-pane-kind.pronunciation { background: var(--jade); color: #fff; }
|
|
353
346
|
.ann-pane-kind.semantic { background: var(--vermillion); color: #fff; }
|
|
354
|
-
.ann-pane-kind.etymology { background:
|
|
347
|
+
.ann-pane-kind.etymology { background: var(--ann-etymology); color: #fff; }
|
|
355
348
|
.ann-pane-kind.note,
|
|
356
349
|
.ann-pane-kind.definition { background: var(--ink); color: var(--paper); }
|
|
357
|
-
.ann-pane-kind.commentary { background:
|
|
358
|
-
.ann-pane-kind.translation { background:
|
|
350
|
+
.ann-pane-kind.commentary { background: var(--ann-commentary); color: #fff; }
|
|
351
|
+
.ann-pane-kind.translation { background: var(--ann-translation); color: #fff; }
|
|
359
352
|
.ann-pane-kind.person { background: var(--ann-person); color: #fff; }
|
|
360
353
|
.ann-pane-kind.place { background: var(--ann-place); color: #fff; }
|
|
361
354
|
.ann-pane-kind.event { background: var(--ann-event); color: #fff; }
|
|
@@ -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> = {}
|
|
@@ -216,6 +199,7 @@ onBeforeUnmount(() => {
|
|
|
216
199
|
/* ─── Annotation entry ─── */
|
|
217
200
|
.ann-entry {
|
|
218
201
|
border-bottom: 1px solid var(--border-light);
|
|
202
|
+
padding: 8px 0;
|
|
219
203
|
font-size: 14px;
|
|
220
204
|
color: var(--ink-mid);
|
|
221
205
|
letter-spacing: 1.5px;
|
|
@@ -246,11 +230,11 @@ onBeforeUnmount(() => {
|
|
|
246
230
|
}
|
|
247
231
|
.ann-kind.pronunciation { background: var(--jade); color: #fff; }
|
|
248
232
|
.ann-kind.semantic { background: var(--vermillion); color: #fff; }
|
|
249
|
-
.ann-kind.etymology { background:
|
|
233
|
+
.ann-kind.etymology { background: var(--ann-etymology); color: #fff; }
|
|
250
234
|
.ann-kind.note,
|
|
251
235
|
.ann-kind.definition { background: var(--ink); color: var(--paper); }
|
|
252
|
-
.ann-kind.commentary { background:
|
|
253
|
-
.ann-kind.translation { background:
|
|
236
|
+
.ann-kind.commentary { background: var(--ann-commentary); color: #fff; }
|
|
237
|
+
.ann-kind.translation { background: var(--ann-translation); color: #fff; }
|
|
254
238
|
.ann-kind.person { background: var(--ann-person); color: #fff; }
|
|
255
239
|
.ann-kind.place { background: var(--ann-place); color: #fff; }
|
|
256
240
|
.ann-kind.event { background: var(--ann-event); color: #fff; }
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { ref, onMounted, onUnmounted } from 'vue'
|
|
2
|
+
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
vertical?: boolean
|
|
6
|
+
scrollContainer?: HTMLElement | null
|
|
7
|
+
}>()
|
|
3
8
|
|
|
4
9
|
const visible = ref(false)
|
|
5
10
|
let ticking = false
|
|
@@ -8,22 +13,52 @@ function onScroll() {
|
|
|
8
13
|
if (ticking) return
|
|
9
14
|
ticking = true
|
|
10
15
|
requestAnimationFrame(() => {
|
|
11
|
-
|
|
16
|
+
if (props.vertical && props.scrollContainer) {
|
|
17
|
+
visible.value = props.scrollContainer.scrollLeft > 400
|
|
18
|
+
} else {
|
|
19
|
+
visible.value = window.scrollY > 400
|
|
20
|
+
}
|
|
12
21
|
ticking = false
|
|
13
22
|
})
|
|
14
23
|
}
|
|
15
24
|
|
|
16
25
|
function scrollToTop() {
|
|
17
|
-
|
|
26
|
+
if (props.vertical && props.scrollContainer) {
|
|
27
|
+
props.scrollContainer.scrollTo({ left: props.scrollContainer.scrollWidth, behavior: 'smooth' })
|
|
28
|
+
} else {
|
|
29
|
+
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function attach() {
|
|
34
|
+
if (props.vertical && props.scrollContainer) {
|
|
35
|
+
props.scrollContainer.addEventListener('scroll', onScroll, { passive: true })
|
|
36
|
+
} else {
|
|
37
|
+
window.addEventListener('scroll', onScroll, { passive: true })
|
|
38
|
+
}
|
|
39
|
+
onScroll()
|
|
18
40
|
}
|
|
19
41
|
|
|
20
|
-
|
|
21
|
-
|
|
42
|
+
function detach() {
|
|
43
|
+
if (props.vertical && props.scrollContainer) {
|
|
44
|
+
props.scrollContainer.removeEventListener('scroll', onScroll)
|
|
45
|
+
} else {
|
|
46
|
+
window.removeEventListener('scroll', onScroll)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
watch(() => props.scrollContainer, () => {
|
|
51
|
+
detach()
|
|
52
|
+
attach()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
onMounted(attach)
|
|
56
|
+
onUnmounted(detach)
|
|
22
57
|
</script>
|
|
23
58
|
|
|
24
59
|
<template>
|
|
25
60
|
<Transition name="btt">
|
|
26
|
-
<button v-if="visible" class="btt" @click="scrollToTop" aria-label="回到頂部">
|
|
61
|
+
<button v-if="visible" class="btt" :class="{ 'btt-v': vertical }" @click="scrollToTop" aria-label="回到頂部">
|
|
27
62
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
28
63
|
<path d="M12 19V5M5 12l7-7 7 7"/>
|
|
29
64
|
</svg>
|
|
@@ -52,6 +87,28 @@ onUnmounted(() => window.removeEventListener('scroll', onScroll))
|
|
|
52
87
|
backdrop-filter: blur(8px);
|
|
53
88
|
-webkit-backdrop-filter: blur(8px);
|
|
54
89
|
}
|
|
90
|
+
.btt.btt-v {
|
|
91
|
+
bottom: auto;
|
|
92
|
+
top: 24px;
|
|
93
|
+
right: auto;
|
|
94
|
+
left: calc(var(--nav-width, 56px) + 12px);
|
|
95
|
+
}
|
|
96
|
+
width: 40px;
|
|
97
|
+
height: 40px;
|
|
98
|
+
border-radius: 50%;
|
|
99
|
+
border: 1px solid var(--border);
|
|
100
|
+
background: var(--surface);
|
|
101
|
+
color: var(--ink-light);
|
|
102
|
+
cursor: pointer;
|
|
103
|
+
z-index: 400;
|
|
104
|
+
display: flex;
|
|
105
|
+
align-items: center;
|
|
106
|
+
justify-content: center;
|
|
107
|
+
box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.1);
|
|
108
|
+
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
109
|
+
backdrop-filter: blur(8px);
|
|
110
|
+
-webkit-backdrop-filter: blur(8px);
|
|
111
|
+
}
|
|
55
112
|
|
|
56
113
|
@media (max-width: 768px) {
|
|
57
114
|
.btt { bottom: 88px; right: 16px; width: 36px; height: 36px; }
|
|
@@ -61,7 +118,7 @@ onUnmounted(() => window.removeEventListener('scroll', onScroll))
|
|
|
61
118
|
color: #fff;
|
|
62
119
|
border-color: var(--vermillion);
|
|
63
120
|
transform: translateY(-3px);
|
|
64
|
-
box-shadow: 0 8px 24px rgba(
|
|
121
|
+
box-shadow: 0 8px 24px rgba(var(--shadow-rgb), 0.15);
|
|
65
122
|
}
|
|
66
123
|
|
|
67
124
|
.btt-enter-active { transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
|
@@ -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"
|
|
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
|
/* ─── 直排卡片:固定寬度,最小高度 ─── */
|
|
@@ -127,7 +128,7 @@ const preview = computed(() => {
|
|
|
127
128
|
top: auto; left: 0; bottom: 0;
|
|
128
129
|
width: 0; height: 3px;
|
|
129
130
|
background: linear-gradient(90deg, var(--gold), var(--vermillion));
|
|
130
|
-
transition: width 0.35s ease;
|
|
131
|
+
transition: width 0.35s var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1));
|
|
131
132
|
}
|
|
132
133
|
.pc-vertical:hover {
|
|
133
134
|
transform: translateX(-4px);
|
|
@@ -75,12 +75,12 @@ onUnmounted(detach)
|
|
|
75
75
|
top: 0; left: 0;
|
|
76
76
|
height: 3px;
|
|
77
77
|
background: linear-gradient(90deg, var(--vermillion), var(--gold));
|
|
78
|
-
box-shadow: 0 0 8px rgba(
|
|
78
|
+
box-shadow: 0 0 8px rgba(var(--shadow-rgb), 0.15);
|
|
79
79
|
}
|
|
80
80
|
.rp-v {
|
|
81
81
|
top: 0; left: 0;
|
|
82
82
|
width: 3px;
|
|
83
83
|
background: linear-gradient(180deg, var(--vermillion), var(--gold));
|
|
84
|
-
box-shadow: 0 0 8px rgba(
|
|
84
|
+
box-shadow: 0 0 8px rgba(var(--shadow-rgb), 0.15);
|
|
85
85
|
}
|
|
86
86
|
</style>
|
|
@@ -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
|
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
--ann-event: #6b4c8a;
|
|
23
23
|
--ann-date: #2a7a7a;
|
|
24
24
|
--ann-allusion: #b5651d;
|
|
25
|
+
--ann-etymology: #6b5b95;
|
|
26
|
+
--ann-commentary: #c0392b;
|
|
27
|
+
--ann-translation: #2c6e49;
|
|
25
28
|
--border: #d8cdb8;
|
|
26
29
|
--border-light: #e8e0d0;
|
|
27
30
|
--shadow-rgb: 26,26,26;
|
|
@@ -48,6 +51,9 @@
|
|
|
48
51
|
--ann-event: #6b4c8a;
|
|
49
52
|
--ann-date: #2a7a7a;
|
|
50
53
|
--ann-allusion: #b5651d;
|
|
54
|
+
--ann-etymology: #6b5b95;
|
|
55
|
+
--ann-commentary: #c0392b;
|
|
56
|
+
--ann-translation: #2c6e49;
|
|
51
57
|
--border: #c9b896;
|
|
52
58
|
--border-light: #d8cab0;
|
|
53
59
|
--shadow-rgb: 74,63,46;
|
|
@@ -74,6 +80,9 @@
|
|
|
74
80
|
--ann-event: #9a7cb4;
|
|
75
81
|
--ann-date: #5ab4b4;
|
|
76
82
|
--ann-allusion: #d4843a;
|
|
83
|
+
--ann-etymology: #9a8ab4;
|
|
84
|
+
--ann-commentary: #e06050;
|
|
85
|
+
--ann-translation: #5ab48a;
|
|
77
86
|
--border: #48484a;
|
|
78
87
|
--border-light: #555557;
|
|
79
88
|
--shadow-rgb: 0,0,0;
|
|
@@ -100,6 +109,9 @@
|
|
|
100
109
|
--ann-event: #9a7cb4;
|
|
101
110
|
--ann-date: #5ab4b4;
|
|
102
111
|
--ann-allusion: #d4843a;
|
|
112
|
+
--ann-etymology: #9a8ab4;
|
|
113
|
+
--ann-commentary: #ff5050;
|
|
114
|
+
--ann-translation: #40c890;
|
|
103
115
|
--border: #333333;
|
|
104
116
|
--border-light: #444444;
|
|
105
117
|
--shadow-rgb: 0,0,0;
|
|
@@ -138,7 +150,6 @@ body {
|
|
|
138
150
|
::-webkit-scrollbar-track { background: transparent; }
|
|
139
151
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
140
152
|
::-webkit-scrollbar-thumb:hover { background: var(--ink-faint); }
|
|
141
|
-
html[dir="rtl"] ::-webkit-scrollbar-thumb { background: var(--gold); }
|
|
142
153
|
|
|
143
154
|
a { color: inherit; text-decoration: none; }
|
|
144
155
|
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 }}
|
|
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"
|
|
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">←
|
|
85
|
+
<button class="h-back" @click="goBack">← {{ t('nav.back') }}</button>
|
|
80
86
|
<div class="h-breadcrumb">
|
|
81
|
-
<span class="h-sep"
|
|
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 || '
|
|
87
|
-
<span class="h-tag">{{ authorPieces.length }}
|
|
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 }}
|
|
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
|
|
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
|
|
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
|
|
130
|
-
<
|
|
140
|
+
<div v-else class="page-loading">
|
|
141
|
+
<div class="page-loading-seal">文</div>
|
|
131
142
|
</div>
|
|
132
143
|
</template>
|
|
133
144
|
|
|
@@ -222,6 +233,9 @@ function goHome() { router.push('/') }
|
|
|
222
233
|
border-color: var(--gold);
|
|
223
234
|
box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.08);
|
|
224
235
|
}
|
|
236
|
+
.v-work:active {
|
|
237
|
+
transform: scale(0.97);
|
|
238
|
+
}
|
|
225
239
|
.v-work-num {
|
|
226
240
|
font-size: 11px; color: var(--ink-faint);
|
|
227
241
|
font-family: var(--sans); letter-spacing: 2px;
|
|
@@ -321,6 +335,9 @@ function goHome() { router.push('/') }
|
|
|
321
335
|
box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.08);
|
|
322
336
|
transform: translateY(-2px);
|
|
323
337
|
}
|
|
338
|
+
.h-work:active {
|
|
339
|
+
transform: scale(0.98);
|
|
340
|
+
}
|
|
324
341
|
.h-work-num { font-size: 11px; color: var(--ink-faint); font-family: var(--sans); letter-spacing: 2px; }
|
|
325
342
|
.h-work-title { font-size: 18px; font-weight: 700; letter-spacing: 2px; margin: 6px 0 4px; }
|
|
326
343
|
.h-work-preview {
|
|
@@ -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
|
|
|
@@ -336,6 +336,10 @@ function scrollToCatalog() {
|
|
|
336
336
|
transform: translateY(-2px);
|
|
337
337
|
box-shadow: 0 12px 40px rgba(var(--shadow-rgb), 0.12);
|
|
338
338
|
}
|
|
339
|
+
.h-cta:active {
|
|
340
|
+
transform: scale(0.97);
|
|
341
|
+
box-shadow: 0 4px 12px rgba(var(--shadow-rgb), 0.08);
|
|
342
|
+
}
|
|
339
343
|
|
|
340
344
|
.h-catalog { max-width: 1200px; margin: 0 auto; padding: 80px 40px; }
|
|
341
345
|
.h-catalog-header { text-align: center; margin-bottom: 60px; }
|
|
@@ -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
|
-
:
|
|
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
|
|
288
|
-
.v-spine
|
|
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
|
|
306
|
-
.v-spine
|
|
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, {
|
|
236
|
-
background: {
|
|
237
|
-
analysis: {
|
|
238
|
-
preparation: {
|
|
239
|
-
follow_up: {
|
|
240
|
-
think_questions: {
|
|
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,
|
|
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:
|
|
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
|
|
@@ -531,6 +530,7 @@ function tcy(n: number): string {
|
|
|
531
530
|
</div>
|
|
532
531
|
</Transition>
|
|
533
532
|
</Teleport>
|
|
533
|
+
<BackToTop vertical :scroll-container="vPageRef" />
|
|
534
534
|
</div>
|
|
535
535
|
|
|
536
536
|
<!-- ═══════ 橫排模式 ═══════ -->
|
|
@@ -695,7 +695,7 @@ function tcy(n: number): string {
|
|
|
695
695
|
{{ p.trim() }}
|
|
696
696
|
</div>
|
|
697
697
|
</div>
|
|
698
|
-
<div v-if="!selectedAuthorBio" class="h-pane-empty"
|
|
698
|
+
<div v-if="!selectedAuthorBio" class="h-pane-empty">{{ t('piece.noAuthorData') }}</div>
|
|
699
699
|
</div>
|
|
700
700
|
</div>
|
|
701
701
|
</Transition>
|