@hanology/cham-browser 0.3.8 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
 
@@ -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) 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': '橫排',
@@ -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)
@@ -1,11 +1,32 @@
1
- export interface SiteConfig {
2
- logoUrl?: string
3
- }
1
+ import { computed } from 'vue'
2
+ import { useReadingMode } from './useReadingMode'
3
+ import type { Theme } from './useReadingMode'
4
+
5
+ const LOGO_LIGHT = import.meta.env.CHAM_LOGO_URL || undefined
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 || ''
4
10
 
5
- const config: SiteConfig = {
6
- logoUrl: import.meta.env.CHAM_LOGO_URL || undefined,
11
+ const DARK_THEMES: Theme[] = ['dark', 'oled']
12
+
13
+ export interface SiteConfig {
14
+ siteTitle: string
15
+ siteSubtitle: string
16
+ aboutHtml: string
17
+ logoUrl: ReturnType<typeof computed<string | undefined>>
7
18
  }
8
19
 
9
20
  export function useSiteConfig(): SiteConfig {
10
- return config
21
+ const { theme } = useReadingMode()
22
+ const logoUrl = computed(() => {
23
+ if (DARK_THEMES.includes(theme.value) && LOGO_DARK) return LOGO_DARK
24
+ return LOGO_LIGHT
25
+ })
26
+ return {
27
+ siteTitle: SITE_TITLE,
28
+ siteSubtitle: SITE_SUBTITLE,
29
+ aboutHtml: ABOUT_HTML,
30
+ logoUrl,
31
+ }
11
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 },
@@ -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
+ }
@@ -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">篇 目 錄</span>
77
+ <span class="v-ch-title">{{ t('catalog.title') }}</span>
76
78
  <span class="v-ch-line"> </span>
77
- <span class="v-count">共 {{ filtered.length }} 篇</span>
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">篇詩文</div>
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">位作者</div>
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>篇 目 錄</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-stats { gap: 24px; }
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-catalog { padding: 40px 20px; }
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>