@markuxt/markuxt 0.1.4

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 (94) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +168 -0
  3. package/app.config.d.ts +33 -0
  4. package/nuxt.config.ts +170 -0
  5. package/package.json +43 -0
  6. package/src/components/AppFooter.vue +225 -0
  7. package/src/components/AppHeader.vue +342 -0
  8. package/src/components/Hero.vue +438 -0
  9. package/src/components/Icon.vue +131 -0
  10. package/src/components/LanguageSwitcher.vue +71 -0
  11. package/src/components/MemberCard.vue +198 -0
  12. package/src/components/MembersGrid.vue +129 -0
  13. package/src/components/MermaidDiagram.vue +99 -0
  14. package/src/components/NewsCard.vue +119 -0
  15. package/src/components/PublicationCard.vue +245 -0
  16. package/src/components/SectionTitle.vue +75 -0
  17. package/src/components/content/ProseImg.vue +29 -0
  18. package/src/components/content/ProsePre.vue +58 -0
  19. package/src/components/content/ProseVideo.vue +45 -0
  20. package/src/composables/resolveContentImage.ts +35 -0
  21. package/src/content-transformers/binary-assets.ts +20 -0
  22. package/src/error.vue +58 -0
  23. package/src/layouts/default.vue +37 -0
  24. package/src/middleware/navigation-guard.ts +22 -0
  25. package/src/pages/index.vue +232 -0
  26. package/src/pages/members/[...slug].vue +542 -0
  27. package/src/pages/members/index.vue +147 -0
  28. package/src/pages/news/[...slug].vue +280 -0
  29. package/src/pages/news/index.vue +102 -0
  30. package/src/pages/positions/[...slug].vue +425 -0
  31. package/src/pages/positions/index.vue +266 -0
  32. package/src/pages/projects/[...slug].vue +441 -0
  33. package/src/pages/projects/index.vue +499 -0
  34. package/src/pages/publications/[...slug].vue +435 -0
  35. package/src/pages/publications/index.vue +145 -0
  36. package/src/plugins/mathml-components.ts +33 -0
  37. package/src/plugins/suppress-warnings.ts +40 -0
  38. package/src/public/_markuxt/components/AppFooter.vue +225 -0
  39. package/src/public/_markuxt/components/AppHeader.vue +342 -0
  40. package/src/public/_markuxt/components/Hero.vue +430 -0
  41. package/src/public/_markuxt/components/Icon.vue +131 -0
  42. package/src/public/_markuxt/components/LanguageSwitcher.vue +71 -0
  43. package/src/public/_markuxt/components/MemberCard.vue +198 -0
  44. package/src/public/_markuxt/components/MembersGrid.vue +129 -0
  45. package/src/public/_markuxt/components/MermaidDiagram.vue +99 -0
  46. package/src/public/_markuxt/components/NewsCard.vue +119 -0
  47. package/src/public/_markuxt/components/PublicationCard.vue +245 -0
  48. package/src/public/_markuxt/components/SectionTitle.vue +75 -0
  49. package/src/public/_markuxt/components/content/ProseImg.vue +29 -0
  50. package/src/public/_markuxt/components/content/ProsePre.vue +58 -0
  51. package/src/public/_markuxt/components/content/ProseVideo.vue +45 -0
  52. package/src/public/_markuxt/composables/resolveContentImage.ts +35 -0
  53. package/src/public/_markuxt/content-transformers/binary-assets.ts +20 -0
  54. package/src/public/_markuxt/error.vue +58 -0
  55. package/src/public/_markuxt/layouts/default.vue +37 -0
  56. package/src/public/_markuxt/middleware/navigation-guard.ts +22 -0
  57. package/src/public/_markuxt/pages/index.vue +232 -0
  58. package/src/public/_markuxt/pages/members/[...slug].vue +542 -0
  59. package/src/public/_markuxt/pages/members/index.vue +147 -0
  60. package/src/public/_markuxt/pages/news/[...slug].vue +280 -0
  61. package/src/public/_markuxt/pages/news/index.vue +102 -0
  62. package/src/public/_markuxt/pages/positions/[...slug].vue +425 -0
  63. package/src/public/_markuxt/pages/positions/index.vue +266 -0
  64. package/src/public/_markuxt/pages/projects/[...slug].vue +441 -0
  65. package/src/public/_markuxt/pages/projects/index.vue +499 -0
  66. package/src/public/_markuxt/pages/publications/[...slug].vue +435 -0
  67. package/src/public/_markuxt/pages/publications/index.vue +145 -0
  68. package/src/public/_markuxt/plugins/mathml-components.ts +33 -0
  69. package/src/public/_markuxt/plugins/suppress-warnings.ts +40 -0
  70. package/src/public/_markuxt/server/plugins/content-locale.ts +47 -0
  71. package/src/public/_markuxt/server/plugins/fix-content-anchors.ts +63 -0
  72. package/src/public/_markuxt/styles/_animations.css +99 -0
  73. package/src/public/_markuxt/styles/_base.css +31 -0
  74. package/src/public/_markuxt/styles/_code.css +109 -0
  75. package/src/public/_markuxt/styles/_components.css +109 -0
  76. package/src/public/_markuxt/styles/_layout.css +220 -0
  77. package/src/public/_markuxt/styles/_markdown.css +52 -0
  78. package/src/public/_markuxt/styles/_tokens.css +62 -0
  79. package/src/public/_markuxt/styles/_typography.css +144 -0
  80. package/src/public/_markuxt/styles/_utilities.css +110 -0
  81. package/src/public/_markuxt/styles/main.css +784 -0
  82. package/src/public/images/logo.png +0 -0
  83. package/src/server/plugins/content-locale.ts +47 -0
  84. package/src/server/plugins/fix-content-anchors.ts +63 -0
  85. package/src/styles/_animations.css +99 -0
  86. package/src/styles/_base.css +31 -0
  87. package/src/styles/_code.css +109 -0
  88. package/src/styles/_components.css +109 -0
  89. package/src/styles/_layout.css +220 -0
  90. package/src/styles/_markdown.css +52 -0
  91. package/src/styles/_tokens.css +62 -0
  92. package/src/styles/_typography.css +144 -0
  93. package/src/styles/_utilities.css +110 -0
  94. package/src/styles/main.css +784 -0
@@ -0,0 +1,438 @@
1
+ <template>
2
+ <section class="hero">
3
+ <div class="container hero__container">
4
+ <div class="hero__grid">
5
+ <div class="hero__content">
6
+ <span class="hero__badge">{{ badge || t('hero.badge') }}</span>
7
+ <h1 class="hero__title">{{ title || t('hero.title') }}</h1>
8
+ <p class="hero__description">{{ description || t('hero.description') }}</p>
9
+ <div class="hero__actions">
10
+ <NuxtLink to="/members" class="btn btn-primary">
11
+ {{ t('hero.meetTeam') }}
12
+ </NuxtLink>
13
+ <NuxtLink to="/publications" class="btn btn-secondary">
14
+ {{ t('hero.viewResearch') }}
15
+ </NuxtLink>
16
+ </div>
17
+ </div>
18
+ <div class="hero__visual">
19
+ <!-- Image Carousel -->
20
+ <div class="carousel">
21
+ <div class="carousel__track" :style="{ transform: `translateX(-${currentSlide * 100}%)` }">
22
+ <div
23
+ v-for="(slide, index) in carouselImages"
24
+ :key="index"
25
+ class="carousel__slide"
26
+ >
27
+ <img :src="slide.src" :alt="slide.alt" class="carousel__image" />
28
+ <div class="carousel__caption" v-if="slide.caption">
29
+ <span>{{ slide.caption }}</span>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ <!-- Navigation Dots -->
34
+ <div class="carousel__dots" v-if="carouselImages.length > 1">
35
+ <button
36
+ v-for="(_, index) in carouselImages"
37
+ :key="index"
38
+ class="carousel__dot"
39
+ :class="{ 'carousel__dot--active': currentSlide === index }"
40
+ @click="goToSlide(index)"
41
+ :aria-label="t('hero.goToSlide', { n: index + 1 })"
42
+ ></button>
43
+ </div>
44
+ <!-- Navigation Arrows -->
45
+ <button
46
+ v-if="carouselImages.length > 1"
47
+ class="carousel__arrow carousel__arrow--prev"
48
+ @click="prevSlide"
49
+ :aria-label="t('hero.prevSlide')"
50
+ >
51
+ <Left class="icon-inline" theme="outline" :size="20" fill="currentColor" :stroke-width="3" />
52
+ </button>
53
+ <button
54
+ v-if="carouselImages.length > 1"
55
+ class="carousel__arrow carousel__arrow--next"
56
+ @click="nextSlide"
57
+ :aria-label="t('hero.nextSlide')"
58
+ >
59
+ <Right class="icon-inline" theme="outline" :size="20" fill="currentColor" :stroke-width="3" />
60
+ </button>
61
+ <!-- Progress Bar -->
62
+ <div class="carousel__progress" v-if="carouselImages.length > 1">
63
+ <div class="carousel__progress-bar" :style="{ width: `${((currentSlide + 1) / carouselImages.length) * 100}%` }"></div>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ </section>
70
+ </template>
71
+
72
+ <script setup lang="ts">
73
+ import Left from '@icon-park/vue-next/es/icons/Left'
74
+ import Right from '@icon-park/vue-next/es/icons/Right'
75
+
76
+ interface Props {
77
+ badge?: string
78
+ title?: string
79
+ description?: string
80
+ }
81
+
82
+ interface CarouselImage {
83
+ src: string
84
+ alt: string
85
+ caption: string
86
+ }
87
+
88
+ const { t } = useI18n()
89
+
90
+ withDefaults(defineProps<Props>(), {
91
+ badge: '',
92
+ title: '',
93
+ description: ''
94
+ })
95
+
96
+ const config = useRuntimeConfig()
97
+ const appConfig = useAppConfig()
98
+
99
+ // Carousel images from app.config — synchronous, SSR-safe
100
+ const carouselImages = computed((): CarouselImage[] => {
101
+ const images = (appConfig.markuxt as Record<string, any>)?.carousel?.images as CarouselImage[] | undefined
102
+ const basePath = config.app.baseURL || '/'
103
+ const base = basePath === '/' ? '' : basePath
104
+
105
+ if (!images || images.length === 0) {
106
+ const fallback = (appConfig.markuxt as Record<string, any>)?.carousel?.fallbackImage || '/images/logo.png'
107
+ return [{
108
+ src: `${base}${fallback}`,
109
+ alt: t('hero.placeholderAlt'),
110
+ caption: t('hero.placeholderCaption')
111
+ }]
112
+ }
113
+
114
+ return images.map(img => ({
115
+ ...img,
116
+ src: `${base}${img.src}`
117
+ }))
118
+ })
119
+
120
+ const currentSlide = ref(0)
121
+ const autoPlayInterval = ref<ReturnType<typeof setInterval> | null>(null)
122
+ const isAutoPlaying = ref(false)
123
+
124
+ const nextSlide = () => {
125
+ if (carouselImages.value.length === 0) return
126
+ currentSlide.value = (currentSlide.value + 1) % carouselImages.value.length
127
+ }
128
+
129
+ const prevSlide = () => {
130
+ if (carouselImages.value.length === 0) return
131
+ currentSlide.value = currentSlide.value === 0 ? carouselImages.value.length - 1 : currentSlide.value - 1
132
+ }
133
+
134
+ const goToSlide = (index: number) => {
135
+ currentSlide.value = index
136
+ stopAutoPlay()
137
+ }
138
+
139
+ const startAutoPlay = () => {
140
+ if (carouselImages.value.length > 1 && !isAutoPlaying.value) {
141
+ isAutoPlaying.value = true
142
+ autoPlayInterval.value = setInterval(() => {
143
+ nextSlide()
144
+ }, 5000)
145
+ }
146
+ }
147
+
148
+ const stopAutoPlay = () => {
149
+ if (autoPlayInterval.value) {
150
+ clearInterval(autoPlayInterval.value)
151
+ autoPlayInterval.value = null
152
+ }
153
+ isAutoPlaying.value = false
154
+ }
155
+
156
+ onMounted(() => {
157
+ startAutoPlay()
158
+ })
159
+
160
+ onUnmounted(() => {
161
+ stopAutoPlay()
162
+ })
163
+ </script>
164
+
165
+ <style scoped>
166
+ .hero {
167
+ min-height: 100vh;
168
+ display: flex;
169
+ align-items: center;
170
+ padding-top: var(--header-height);
171
+ position: relative;
172
+ overflow: hidden;
173
+ }
174
+
175
+ .hero::before {
176
+ content: '';
177
+ position: absolute;
178
+ top: 0;
179
+ left: 0;
180
+ right: 0;
181
+ bottom: 0;
182
+ background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%);
183
+ opacity: 0.03;
184
+ z-index: -1;
185
+ }
186
+
187
+ .hero__container {
188
+ padding-inline: var(--spacing-2xl);
189
+ }
190
+
191
+ .hero__grid {
192
+ display: grid;
193
+ grid-template-columns: 1.2fr 1fr;
194
+ gap: var(--spacing-3xl);
195
+ align-items: center;
196
+ }
197
+
198
+ .hero__content {
199
+ animation: fadeInUp 0.8s ease forwards;
200
+ }
201
+
202
+ .hero__badge {
203
+ display: inline-flex;
204
+ align-items: center;
205
+ padding: var(--spacing-xs) var(--spacing-md);
206
+ font-size: 0.75rem;
207
+ font-weight: 600;
208
+ letter-spacing: 0.1em;
209
+ text-transform: uppercase;
210
+ color: var(--color-accent);
211
+ background: rgba(0,217,255,0.1);
212
+ border-radius: var(--radius-full);
213
+ margin-bottom: var(--spacing-lg);
214
+ }
215
+
216
+ .hero__title {
217
+ font-family: var(--font-display);
218
+ font-size: clamp(2.5rem, 5vw, 3.5rem);
219
+ font-weight: 800;
220
+ line-height: 1.1;
221
+ color: var(--color-primary);
222
+ margin-bottom: var(--spacing-lg);
223
+ }
224
+
225
+ .hero__title span {
226
+ color: var(--color-secondary);
227
+ }
228
+
229
+ .hero__description {
230
+ font-size: 1.125rem;
231
+ line-height: 1.7;
232
+ color: var(--color-text-muted);
233
+ margin-bottom: var(--spacing-xl);
234
+ max-width: 540px;
235
+ }
236
+
237
+ .hero__actions {
238
+ display: flex;
239
+ flex-wrap: wrap;
240
+ gap: var(--spacing-md);
241
+ }
242
+
243
+ /* Carousel */
244
+ .hero__visual {
245
+ position: relative;
246
+ /* 4:3 aspect ratio */
247
+ aspect-ratio: 4 / 3;
248
+ width: 100%;
249
+ /* max-height: 500px; */
250
+ animation: fadeIn 1s ease 0.3s forwards;
251
+ opacity: 0;
252
+ }
253
+
254
+ .carousel {
255
+ position: relative;
256
+ width: 100%;
257
+ height: 100%;
258
+ border-radius: var(--radius-xl);
259
+ overflow: hidden;
260
+ box-shadow: var(--shadow-xl);
261
+ background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%);
262
+ }
263
+
264
+ .carousel__track {
265
+ display: flex;
266
+ height: 100%;
267
+ transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
268
+ }
269
+
270
+ .carousel__slide {
271
+ min-width: 100%;
272
+ height: 100%;
273
+ position: relative;
274
+ }
275
+
276
+ .carousel__image {
277
+ width: 100%;
278
+ height: 100%;
279
+ object-fit: cover;
280
+ }
281
+
282
+ .carousel__caption {
283
+ position: absolute;
284
+ bottom: 0;
285
+ left: 0;
286
+ right: 0;
287
+ padding: var(--spacing-lg);
288
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
289
+ color: white;
290
+ }
291
+
292
+ .carousel__caption span {
293
+ font-size: 0.9375rem;
294
+ font-weight: 500;
295
+ letter-spacing: 0.02em;
296
+ }
297
+
298
+ .carousel__dots {
299
+ position: absolute;
300
+ bottom: var(--spacing-md);
301
+ left: 50%;
302
+ transform: translateX(-50%);
303
+ display: flex;
304
+ gap: var(--spacing-xs);
305
+ z-index: 10;
306
+ }
307
+
308
+ .carousel__dot {
309
+ width: 10px;
310
+ height: 10px;
311
+ border-radius: 50%;
312
+ background: rgba(255, 255, 255, 0.5);
313
+ border: none;
314
+ cursor: pointer;
315
+ transition: all var(--transition-fast);
316
+ padding: 0;
317
+ }
318
+
319
+ .carousel__dot:hover {
320
+ background: rgba(255, 255, 255, 0.8);
321
+ }
322
+
323
+ .carousel__dot--active {
324
+ background: white;
325
+ width: 24px;
326
+ border-radius: var(--radius-full);
327
+ }
328
+
329
+ /* Navigation Arrows */
330
+ .carousel__arrow {
331
+ position: absolute;
332
+ top: 50%;
333
+ transform: translateY(-50%);
334
+ width: 36px;
335
+ height: 36px;
336
+ border-radius: 50%;
337
+ background: rgba(255, 255, 255, 0.9);
338
+ border: none;
339
+ cursor: pointer;
340
+ display: flex;
341
+ align-items: center;
342
+ justify-content: center;
343
+ color: var(--color-primary);
344
+ transition: all var(--transition-fast);
345
+ z-index: 10;
346
+ }
347
+
348
+ .carousel__arrow:hover,
349
+ .carousel__arrow:focus {
350
+ background: white;
351
+ box-shadow: var(--shadow-md);
352
+ transform: translateY(-50%) scale(1.1);
353
+ }
354
+
355
+ .carousel__arrow--prev {
356
+ left: var(--spacing-md);
357
+ }
358
+
359
+ .carousel__arrow--next {
360
+ right: var(--spacing-md);
361
+ }
362
+
363
+ .carousel__progress {
364
+ position: absolute;
365
+ bottom: 0;
366
+ left: 0;
367
+ right: 0;
368
+ height: 3px;
369
+ background: rgba(255, 255, 255, 0.2);
370
+ }
371
+
372
+ .carousel__progress-bar {
373
+ height: 100%;
374
+ background: linear-gradient(90deg, var(--color-accent), var(--color-secondary));
375
+ transition: width 0.3s ease;
376
+ }
377
+
378
+ /* Reduced motion support */
379
+ @media (prefers-reduced-motion) {
380
+ .hero__content,
381
+ .hero__visual,
382
+ .carousel__track,
383
+ .carousel__arrow {
384
+ animation: none !important;
385
+ transition: none !important;
386
+ }
387
+
388
+ .carousel__arrow:hover,
389
+ .carousel__arrow:focus {
390
+ transform: translateY(-50%);
391
+ }
392
+ }
393
+
394
+ /* Responsive */
395
+ @media (max-width: 768px) {
396
+ .hero {
397
+ min-height: auto;
398
+ padding-top: calc(var(--header-height) + var(--spacing-xl));
399
+ padding-bottom: var(--spacing-2xl);
400
+ }
401
+
402
+ .hero__container {
403
+ padding-inline: var(--spacing-xl);
404
+ }
405
+
406
+ .hero__grid {
407
+ grid-template-columns: 1fr;
408
+ gap: var(--spacing-2xl);
409
+ }
410
+
411
+ .hero__visual {
412
+ order: -1;
413
+ /* Keep 4:3 ratio on tablet */
414
+ aspect-ratio: 4 / 3;
415
+ /* max-height: 400px; */
416
+ }
417
+ }
418
+
419
+ @media (max-width: 480px) {
420
+ .hero {
421
+ padding-top: calc(var(--header-height) + var(--spacing-md));
422
+ }
423
+
424
+ .hero__actions {
425
+ flex-direction: column;
426
+ }
427
+
428
+ .hero__actions .btn {
429
+ width: 100%;
430
+ }
431
+
432
+ .hero__visual {
433
+ /* Keep 4:3 ratio on mobile */
434
+ aspect-ratio: 4 / 3;
435
+ max-height: 300px;
436
+ }
437
+ }
438
+ </style>
@@ -0,0 +1,131 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ // Import only the icons we need from IconPark
5
+ import ArrowLeft from '@icon-park/vue-next/es/icons/ArrowLeft'
6
+ import Mail from '@icon-park/vue-next/es/icons/Mail'
7
+ import Google from '@icon-park/vue-next/es/icons/Google'
8
+ import Search from '@icon-park/vue-next/es/icons/Search'
9
+ import FileStaff from '@icon-park/vue-next/es/icons/FileStaff'
10
+ import Close from '@icon-park/vue-next/es/icons/Close'
11
+ import HamburgerButton from '@icon-park/vue-next/es/icons/HamburgerButton'
12
+ import Help from '@icon-park/vue-next/es/icons/Help'
13
+ import LinkOut from '@icon-park/vue-next/es/icons/LinkOut'
14
+
15
+ interface Props {
16
+ name: string
17
+ size?: number | string
18
+ color?: string
19
+ }
20
+
21
+ const props = withDefaults(defineProps<Props>(), {
22
+ size: 20,
23
+ color: 'currentColor'
24
+ })
25
+
26
+ const iconMap: Record<string, any> = {
27
+ 'arrow-left': ArrowLeft,
28
+ 'email': Mail,
29
+ 'google-scholar': Google,
30
+ 'scholar': Google,
31
+ 'research': Search,
32
+ 'about': FileStaff,
33
+ 'close': Close,
34
+ 'menu': HamburgerButton,
35
+ 'question-mark': Help,
36
+ 'external-link': LinkOut
37
+ }
38
+
39
+ const IconComp = computed(() => iconMap[props.name] || null)
40
+
41
+ // Calculate stroke-width based on icon size for optimal visual weight
42
+ // Email icon gets bolder stroke for better visibility
43
+ const strokeWidth = computed(() => {
44
+ const isEmail = props.name === 'email'
45
+ if (typeof props.size === 'string') return isEmail ? 3 : 2
46
+ if (isEmail) {
47
+ // Email icon is always bolder
48
+ if (props.size <= 14) return 4
49
+ if (props.size <= 18) return 3.5
50
+ return 3
51
+ }
52
+ // Standard stroke width for other icons (increased for stronger weight)
53
+ if (props.size <= 14) return 3
54
+ if (props.size <= 18) return 2.5
55
+ return 2
56
+ })
57
+
58
+ // Handle both numeric (px) and string (CSS) sizes
59
+ const numericSize = computed(() => typeof props.size === 'number' ? props.size : 20)
60
+ </script>
61
+
62
+ <template>
63
+ <!-- Icon wrapper with proper flex centering -->
64
+ <span class="icon-container">
65
+ <!-- For Vue components with numeric size -->
66
+ <component
67
+ v-if="IconComp && typeof size === 'number'"
68
+ :is="IconComp"
69
+ theme="outline"
70
+ :size="numericSize"
71
+ :fill="color || 'currentColor'"
72
+ :stroke-width="strokeWidth"
73
+ class="icon-component"
74
+ />
75
+
76
+ <!-- For CSS sizing (container-relative) -->
77
+ <span
78
+ v-else-if="IconComp"
79
+ class="icon-scaled-wrapper"
80
+ >
81
+ <component
82
+ :is="IconComp"
83
+ theme="outline"
84
+ :size="24"
85
+ :fill="color || 'currentColor'"
86
+ :stroke-width="name === 'email' ? 3 : 1.5"
87
+ class="icon-scaled"
88
+ />
89
+ </span>
90
+
91
+ <!-- Fallback to Iconify CDN if icon name not recognised -->
92
+ <!-- <img
93
+ v-else
94
+ :src="`https://api.iconify.design/ep-icon-park-outline/${props.name}.svg`"
95
+ :alt="`${props.name} icon`"
96
+ loading="lazy"
97
+ class="icon-fallback"
98
+ /> -->
99
+ </span>
100
+ </template>
101
+
102
+ <style scoped>
103
+ .icon-container {
104
+ display: inline-flex;
105
+ align-items: center;
106
+ justify-content: center;
107
+ vertical-align: middle;
108
+ flex-shrink: 0;
109
+ }
110
+
111
+ .icon-component {
112
+ display: block;
113
+ line-height: 1;
114
+ }
115
+
116
+ .icon-scaled-wrapper {
117
+ display: inline-flex;
118
+ align-items: center;
119
+ justify-content: center;
120
+ width: 100%;
121
+ height: 100%;
122
+ }
123
+
124
+ .icon-scaled,
125
+ .icon-fallback {
126
+ width: 100%;
127
+ height: 100%;
128
+ display: block;
129
+ object-fit: contain;
130
+ }
131
+ </style>
@@ -0,0 +1,71 @@
1
+ <template>
2
+ <div class="lang-switcher">
3
+ <button
4
+ v-for="loc in availableLocales"
5
+ :key="loc.code"
6
+ class="lang-switcher__btn"
7
+ :class="{ 'lang-switcher__btn--active': loc.code === locale }"
8
+ @click="setLocale(loc.code as 'en' | 'zh-CN')"
9
+ >
10
+ {{ getLocaleLabel(loc.code) }}
11
+ </button>
12
+ </div>
13
+ </template>
14
+
15
+ <script setup lang="ts">
16
+ const { locale, locales, setLocale } = useI18n()
17
+
18
+ const availableLocales = computed(() =>
19
+ (locales.value as Array<{ code: string; name: string }>)
20
+ )
21
+
22
+ const LABELS: Record<string, string> = {
23
+ 'en': 'EN',
24
+ 'zh-CN': '中文',
25
+ }
26
+
27
+ function getLocaleLabel(code: string): string {
28
+ if (code in LABELS) return LABELS[code]
29
+ console.warn(`[LanguageSwitcher] unknown locale code: "${code}"`)
30
+ return code
31
+ }
32
+ </script>
33
+
34
+ <style scoped>
35
+ .lang-switcher {
36
+ display: flex;
37
+ align-items: center;
38
+ gap: 2px;
39
+ background: var(--color-bg);
40
+ border-radius: var(--radius-full);
41
+ padding: 2px 3px;
42
+ border: 1px solid var(--color-border);
43
+ }
44
+
45
+ .lang-switcher__btn {
46
+ padding: 4px 10px;
47
+ font-size: 0.75rem;
48
+ font-weight: 600;
49
+ color: var(--color-text-muted);
50
+ background: transparent;
51
+ border: none;
52
+ border-radius: var(--radius-full);
53
+ cursor: pointer;
54
+ transition: all var(--transition-fast);
55
+ white-space: nowrap;
56
+ }
57
+
58
+ .lang-switcher__btn:hover {
59
+ color: var(--color-text);
60
+ }
61
+
62
+ .lang-switcher__btn--active {
63
+ background: var(--color-secondary);
64
+ color: white;
65
+ }
66
+
67
+ .lang-switcher__btn--active:hover {
68
+ background: var(--color-primary);
69
+ color: white;
70
+ }
71
+ </style>