@hanology/cham-browser 0.3.9 → 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.
- package/dist/cli.js +141 -18
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/template/index.html +4 -8
- package/template/src/App.vue +101 -17
- package/template/src/components/AnnotationControlBar.vue +119 -49
- package/template/src/components/AnnotationTooltip.vue +278 -99
- package/template/src/components/BackToTop.vue +4 -0
- package/template/src/components/BookCard.vue +10 -11
- package/template/src/components/HorizontalDisplay.vue +52 -0
- package/template/src/components/PoemCard.vue +1 -0
- package/template/src/components/PronunciationGroup.vue +27 -18
- package/template/src/components/ReadingToolbar.vue +20 -0
- package/template/src/components/SectionBlock.vue +91 -12
- package/template/src/components/SideNav.vue +5 -4
- package/template/src/components/VerticalScroll.vue +30 -0
- package/template/src/composables/useData.ts +6 -1
- package/template/src/composables/useI18n.ts +33 -0
- package/template/src/composables/useReadingMode.ts +9 -4
- package/template/src/composables/useSiteConfig.ts +12 -1
- package/template/src/router.ts +0 -2
- package/template/src/styles/main.css +88 -0
- package/template/src/types.ts +8 -0
- package/template/src/views/BookHome.vue +45 -21
- package/template/src/views/LibraryHome.vue +39 -41
- package/template/src/views/PieceView.vue +434 -71
- package/template/src/views/AboutView.vue +0 -191
|
@@ -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
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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"
|
|
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="
|
|
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
|
|
|
@@ -121,4 +121,34 @@ function onTap(event: MouseEvent) {
|
|
|
121
121
|
:deep(.ann-target.pronunciation.semantic) {
|
|
122
122
|
border-left-color: var(--gold);
|
|
123
123
|
}
|
|
124
|
+
:deep(.ann-target.person) {
|
|
125
|
+
border-left-color: var(--ann-person);
|
|
126
|
+
}
|
|
127
|
+
:deep(.ann-target.place) {
|
|
128
|
+
border-left-color: var(--ann-place);
|
|
129
|
+
}
|
|
130
|
+
:deep(.ann-target.event) {
|
|
131
|
+
border-left-color: var(--ann-event);
|
|
132
|
+
}
|
|
133
|
+
:deep(.ann-target.date) {
|
|
134
|
+
border-left-color: var(--ann-date);
|
|
135
|
+
}
|
|
136
|
+
:deep(.ann-target.allusion) {
|
|
137
|
+
border-left-color: var(--ann-allusion);
|
|
138
|
+
}
|
|
139
|
+
:deep(.ann-target.person:hover) {
|
|
140
|
+
background: rgba(58, 90, 140, 0.08);
|
|
141
|
+
}
|
|
142
|
+
:deep(.ann-target.place:hover) {
|
|
143
|
+
background: rgba(139, 105, 20, 0.08);
|
|
144
|
+
}
|
|
145
|
+
:deep(.ann-target.event:hover) {
|
|
146
|
+
background: rgba(107, 76, 138, 0.08);
|
|
147
|
+
}
|
|
148
|
+
:deep(.ann-target.date:hover) {
|
|
149
|
+
background: rgba(42, 122, 122, 0.08);
|
|
150
|
+
}
|
|
151
|
+
:deep(.ann-target.allusion:hover) {
|
|
152
|
+
background: rgba(181, 101, 29, 0.08);
|
|
153
|
+
}
|
|
124
154
|
</style>
|
|
@@ -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)
|
|
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': '橫排',
|
|
@@ -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': '横排',
|
|
@@ -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',
|
|
@@ -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 {
|
|
26
|
+
return {
|
|
27
|
+
siteTitle: SITE_TITLE,
|
|
28
|
+
siteSubtitle: SITE_SUBTITLE,
|
|
29
|
+
aboutHtml: ABOUT_HTML,
|
|
30
|
+
logoUrl,
|
|
31
|
+
}
|
|
21
32
|
}
|
package/template/src/router.ts
CHANGED
|
@@ -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 },
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
--gold: #9a7d3a;
|
|
16
16
|
--gold-light: #c9a84c;
|
|
17
17
|
--jade: #3a6b5e;
|
|
18
|
+
--ann-person: #3a5a8c;
|
|
19
|
+
--ann-place: #8b6914;
|
|
20
|
+
--ann-event: #6b4c8a;
|
|
21
|
+
--ann-date: #2a7a7a;
|
|
22
|
+
--ann-allusion: #b5651d;
|
|
18
23
|
--border: #d8cdb8;
|
|
19
24
|
--border-light: #e8e0d0;
|
|
20
25
|
--shadow-rgb: 26,26,26;
|
|
@@ -36,6 +41,11 @@
|
|
|
36
41
|
--gold: #8a6d2a;
|
|
37
42
|
--gold-light: #b89540;
|
|
38
43
|
--jade: #2d5a4e;
|
|
44
|
+
--ann-person: #3a5a8c;
|
|
45
|
+
--ann-place: #8b6914;
|
|
46
|
+
--ann-event: #6b4c8a;
|
|
47
|
+
--ann-date: #2a7a7a;
|
|
48
|
+
--ann-allusion: #b5651d;
|
|
39
49
|
--border: #c9b896;
|
|
40
50
|
--border-light: #d8cab0;
|
|
41
51
|
--shadow-rgb: 74,63,46;
|
|
@@ -57,6 +67,11 @@
|
|
|
57
67
|
--gold: #c9a84c;
|
|
58
68
|
--gold-light: #d8b860;
|
|
59
69
|
--jade: #5aaa98;
|
|
70
|
+
--ann-person: #6a8ab4;
|
|
71
|
+
--ann-place: #b8943a;
|
|
72
|
+
--ann-event: #9a7cb4;
|
|
73
|
+
--ann-date: #5ab4b4;
|
|
74
|
+
--ann-allusion: #d4843a;
|
|
60
75
|
--border: #48484a;
|
|
61
76
|
--border-light: #555557;
|
|
62
77
|
--shadow-rgb: 0,0,0;
|
|
@@ -78,6 +93,11 @@
|
|
|
78
93
|
--gold: #e8c840;
|
|
79
94
|
--gold-light: #f0d860;
|
|
80
95
|
--jade: #40c8a8;
|
|
96
|
+
--ann-person: #6a8ab4;
|
|
97
|
+
--ann-place: #b8943a;
|
|
98
|
+
--ann-event: #9a7cb4;
|
|
99
|
+
--ann-date: #5ab4b4;
|
|
100
|
+
--ann-allusion: #d4843a;
|
|
81
101
|
--border: #333333;
|
|
82
102
|
--border-light: #444444;
|
|
83
103
|
--shadow-rgb: 0,0,0;
|
|
@@ -196,3 +216,71 @@ button { font-family: inherit; }
|
|
|
196
216
|
@media (max-width: 768px) {
|
|
197
217
|
:root { --nav-width: 44px; }
|
|
198
218
|
}
|
|
219
|
+
|
|
220
|
+
/* ===== VERTICAL PAGE LAYOUT ===== */
|
|
221
|
+
.v-page {
|
|
222
|
+
height: 100vh;
|
|
223
|
+
display: flex;
|
|
224
|
+
flex-direction: row-reverse;
|
|
225
|
+
overflow-x: auto;
|
|
226
|
+
overflow-y: hidden;
|
|
227
|
+
margin-right: var(--nav-width, 56px);
|
|
228
|
+
scrollbar-width: thin;
|
|
229
|
+
scrollbar-color: var(--gold) transparent;
|
|
230
|
+
scroll-snap-type: x proximity;
|
|
231
|
+
}
|
|
232
|
+
.v-page::-webkit-scrollbar { height: 4px; }
|
|
233
|
+
.v-page::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
|
|
234
|
+
|
|
235
|
+
/* ===== VIEW LOADING STATE ===== */
|
|
236
|
+
.v-loading {
|
|
237
|
+
display: flex;
|
|
238
|
+
flex-direction: column;
|
|
239
|
+
align-items: center;
|
|
240
|
+
justify-content: center;
|
|
241
|
+
min-height: 60vh;
|
|
242
|
+
gap: 16px;
|
|
243
|
+
}
|
|
244
|
+
.v-loading .seal {
|
|
245
|
+
width: 72px; height: 72px;
|
|
246
|
+
border: 2px solid var(--vermillion);
|
|
247
|
+
border-radius: 4px;
|
|
248
|
+
display: flex; align-items: center; justify-content: center;
|
|
249
|
+
animation: pulse 2s ease-in-out infinite;
|
|
250
|
+
}
|
|
251
|
+
.v-loading .char {
|
|
252
|
+
font-family: var(--serif);
|
|
253
|
+
font-size: 36px; font-weight: 900;
|
|
254
|
+
color: var(--vermillion);
|
|
255
|
+
line-height: 1;
|
|
256
|
+
}
|
|
257
|
+
.v-loading .label {
|
|
258
|
+
font-size: 13px;
|
|
259
|
+
color: var(--ink-faint);
|
|
260
|
+
letter-spacing: 4px;
|
|
261
|
+
}
|
|
262
|
+
@keyframes pulse {
|
|
263
|
+
0%, 100% { opacity: 0.6; transform: scale(0.96); }
|
|
264
|
+
50% { opacity: 1; transform: scale(1); }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/* ===== FULL-PAGE LOADING (LibraryHome, PieceView redirect) ===== */
|
|
268
|
+
.page-loading {
|
|
269
|
+
display: flex; flex-direction: column;
|
|
270
|
+
align-items: center; justify-content: center;
|
|
271
|
+
height: 100vh;
|
|
272
|
+
}
|
|
273
|
+
.page-loading-seal {
|
|
274
|
+
width: 56px; height: 56px;
|
|
275
|
+
border: 2px solid var(--vermillion);
|
|
276
|
+
border-radius: 4px;
|
|
277
|
+
display: flex; align-items: center; justify-content: center;
|
|
278
|
+
font-size: 28px; font-weight: 900;
|
|
279
|
+
color: var(--vermillion);
|
|
280
|
+
animation: pulse 1.2s ease-in-out infinite;
|
|
281
|
+
}
|
|
282
|
+
.page-loading-logo {
|
|
283
|
+
width: 56px; height: auto;
|
|
284
|
+
object-fit: contain;
|
|
285
|
+
animation: pulse 1.2s ease-in-out infinite;
|
|
286
|
+
}
|
package/template/src/types.ts
CHANGED
|
@@ -165,6 +165,14 @@ export interface Author {
|
|
|
165
165
|
dynasty: string
|
|
166
166
|
poemCount: number
|
|
167
167
|
bio?: string
|
|
168
|
+
born?: string
|
|
169
|
+
died?: string
|
|
170
|
+
courtesyName?: string
|
|
171
|
+
artName?: string
|
|
172
|
+
wikidata?: string
|
|
173
|
+
ctextId?: string
|
|
174
|
+
wikipediaZh?: string
|
|
175
|
+
wikipediaEn?: string
|
|
168
176
|
}
|
|
169
177
|
|
|
170
178
|
export interface Dynasty {
|
|
@@ -5,6 +5,7 @@ import { useBook } from '../composables/useBook'
|
|
|
5
5
|
import { useTitle } from '../composables/useTitle'
|
|
6
6
|
import { useReadingMode } from '../composables/useReadingMode'
|
|
7
7
|
import { useHorizontalScroll } from '../composables/useHorizontalScroll'
|
|
8
|
+
import { useI18n } from '../composables/useI18n'
|
|
8
9
|
import PoemCard from '../components/PoemCard.vue'
|
|
9
10
|
import SideNav from '../components/SideNav.vue'
|
|
10
11
|
import ReadingToolbar from '../components/ReadingToolbar.vue'
|
|
@@ -22,6 +23,7 @@ const { layout } = useReadingMode()
|
|
|
22
23
|
const isVertical = computed(() => layout.value === 'vertical')
|
|
23
24
|
const vPageRef = ref<HTMLElement | null>(null)
|
|
24
25
|
const vScroll = useHorizontalScroll(vPageRef)
|
|
26
|
+
const { t } = useI18n()
|
|
25
27
|
|
|
26
28
|
const filtered = computed(() => {
|
|
27
29
|
const q = searchQuery.value.toLowerCase()
|
|
@@ -72,11 +74,11 @@ function scrollToCatalog() {
|
|
|
72
74
|
</section>
|
|
73
75
|
|
|
74
76
|
<section class="v-catalog-col">
|
|
75
|
-
<span class="v-ch-title"
|
|
77
|
+
<span class="v-ch-title">{{ t('catalog.title') }}</span>
|
|
76
78
|
<span class="v-ch-line"> </span>
|
|
77
|
-
<span class="v-count"
|
|
79
|
+
<span class="v-count">{{ t('catalog.total', { count: filtered.length }) }}</span>
|
|
78
80
|
<span class="v-search-wrap">
|
|
79
|
-
<input v-model="searchQuery" class="v-search" placeholder="
|
|
81
|
+
<input v-model="searchQuery" class="v-search" :placeholder="t('catalog.search')" />
|
|
80
82
|
</span>
|
|
81
83
|
</section>
|
|
82
84
|
|
|
@@ -104,28 +106,28 @@ function scrollToCatalog() {
|
|
|
104
106
|
<div class="h-stats">
|
|
105
107
|
<div class="h-stat-block">
|
|
106
108
|
<div class="h-stat-num">{{ pieces.length }}</div>
|
|
107
|
-
<div class="h-stat-label"
|
|
109
|
+
<div class="h-stat-label">{{ t('stat.piecePoems') }}</div>
|
|
108
110
|
</div>
|
|
109
111
|
<div class="h-stat-block">
|
|
110
112
|
<div class="h-stat-num">{{ authorCount }}</div>
|
|
111
|
-
<div class="h-stat-label"
|
|
113
|
+
<div class="h-stat-label">{{ t('stat.authorsLabel') }}</div>
|
|
112
114
|
</div>
|
|
113
115
|
</div>
|
|
114
116
|
<p v-if="meta?.publisher" class="h-publisher">{{ meta.publisher }}</p>
|
|
115
117
|
<button class="h-cta" @click="scrollToCatalog">
|
|
116
|
-
|
|
118
|
+
{{ t('catalog.enterLibrary') }}
|
|
117
119
|
</button>
|
|
118
120
|
</div>
|
|
119
121
|
</section>
|
|
120
122
|
|
|
121
123
|
<section class="h-catalog">
|
|
122
124
|
<div class="h-catalog-header">
|
|
123
|
-
<h2
|
|
125
|
+
<h2>{{ t('catalog.title') }}</h2>
|
|
124
126
|
<div class="h-line"></div>
|
|
125
127
|
<p v-if="meta?.publisher">{{ meta.publisher }}</p>
|
|
126
128
|
</div>
|
|
127
129
|
<div class="h-filter">
|
|
128
|
-
<input v-model="searchQuery" class="h-search" placeholder="
|
|
130
|
+
<input v-model="searchQuery" class="h-search" :placeholder="t('catalog.search')" />
|
|
129
131
|
</div>
|
|
130
132
|
<div class="h-grid">
|
|
131
133
|
<PoemCard
|
|
@@ -137,6 +139,10 @@ function scrollToCatalog() {
|
|
|
137
139
|
@click="openPiece(piece.num)"
|
|
138
140
|
/>
|
|
139
141
|
</div>
|
|
142
|
+
<div v-if="searchQuery && filtered.length === 0" class="h-empty">
|
|
143
|
+
<span class="h-empty-icon">🔍</span>
|
|
144
|
+
<p>{{ t('catalog.noResults', { query: searchQuery }) }}</p>
|
|
145
|
+
</div>
|
|
140
146
|
</section>
|
|
141
147
|
|
|
142
148
|
<BackToTop />
|
|
@@ -148,20 +154,9 @@ function scrollToCatalog() {
|
|
|
148
154
|
/* ═══════ 直排模式 ═══════ */
|
|
149
155
|
|
|
150
156
|
.v-page {
|
|
151
|
-
height: 100vh;
|
|
152
|
-
display: flex;
|
|
153
|
-
flex-direction: row-reverse;
|
|
154
|
-
overflow-x: auto;
|
|
155
|
-
overflow-y: hidden;
|
|
156
|
-
margin-right: var(--nav-width, 56px);
|
|
157
157
|
padding: 0 32px;
|
|
158
158
|
background: linear-gradient(90deg, var(--paper) 0%, var(--paper-warm) 100%);
|
|
159
|
-
scrollbar-width: thin;
|
|
160
|
-
scrollbar-color: var(--gold) transparent;
|
|
161
|
-
scroll-snap-type: x proximity;
|
|
162
159
|
}
|
|
163
|
-
.v-page::-webkit-scrollbar { height: 4px; }
|
|
164
|
-
.v-page::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
|
|
165
160
|
|
|
166
161
|
.v-hero {
|
|
167
162
|
writing-mode: vertical-rl;
|
|
@@ -367,10 +362,31 @@ function scrollToCatalog() {
|
|
|
367
362
|
animation: cardEnter 0.4s var(--ease-out-expo) both;
|
|
368
363
|
}
|
|
369
364
|
|
|
365
|
+
.h-empty {
|
|
366
|
+
text-align: center;
|
|
367
|
+
padding: 60px 20px;
|
|
368
|
+
color: var(--ink-faint);
|
|
369
|
+
font-family: var(--sans);
|
|
370
|
+
font-size: 15px;
|
|
371
|
+
letter-spacing: 1px;
|
|
372
|
+
}
|
|
373
|
+
.h-empty-icon {
|
|
374
|
+
display: block;
|
|
375
|
+
font-size: 40px;
|
|
376
|
+
margin-bottom: 16px;
|
|
377
|
+
opacity: 0.5;
|
|
378
|
+
}
|
|
379
|
+
|
|
370
380
|
@media (max-width: 768px) {
|
|
371
|
-
.h-
|
|
381
|
+
.h-hero { min-height: 80vh; height: auto; padding: 60px 16px; }
|
|
382
|
+
.h-ornament { font-size: 32px; letter-spacing: 12px; margin-bottom: 20px; }
|
|
383
|
+
.h-subtitle { margin-bottom: 32px; }
|
|
384
|
+
.h-divider { margin-bottom: 32px; }
|
|
385
|
+
.h-stats { gap: 24px; margin-bottom: 32px; }
|
|
372
386
|
.h-stat-num { font-size: 28px; }
|
|
373
|
-
.h-
|
|
387
|
+
.h-publisher { margin-bottom: 32px; }
|
|
388
|
+
.h-cta { padding: 12px 32px; font-size: 14px; letter-spacing: 2px; }
|
|
389
|
+
.h-catalog { padding: 40px 16px; }
|
|
374
390
|
.h-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; }
|
|
375
391
|
.h-search { width: 100%; }
|
|
376
392
|
.v-page { padding: 0 16px; }
|
|
@@ -380,4 +396,12 @@ function scrollToCatalog() {
|
|
|
380
396
|
}
|
|
381
397
|
.v-search { height: 160px; }
|
|
382
398
|
}
|
|
399
|
+
|
|
400
|
+
@media (max-width: 480px) {
|
|
401
|
+
.h-hero { min-height: 70vh; }
|
|
402
|
+
.h-title { letter-spacing: 6px; }
|
|
403
|
+
.h-cta { width: 80%; justify-content: center; }
|
|
404
|
+
.h-grid { grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
405
|
+
.h-catalog-header h2 { font-size: 22px; }
|
|
406
|
+
}
|
|
383
407
|
</style>
|