@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,245 @@
1
+ <template>
2
+ <article class="publication-card">
3
+ <div class="publication-card__meta">
4
+ <span class="publication-card__year">{{ publication.year }}</span>
5
+ <span v-if="publication.venue" class="badge badge-accent">{{ publication.venue }}</span>
6
+ </div>
7
+ <h3 class="publication-card__title">
8
+ <!-- If there's a detail page (_path), link to it; otherwise show plain title or DOI link -->
9
+ <NuxtLink v-if="publication._path" :to="publication._path" class="publication-card__title-link">
10
+ {{ publication.title }}
11
+ </NuxtLink>
12
+ <a v-else-if="publication.doi" :href="publication.doi" target="_blank" rel="noopener" class="publication-card__title-link">
13
+ {{ publication.title }}
14
+ </a>
15
+ <span v-else class="publication-card__title-text">{{ publication.title }}</span>
16
+ </h3>
17
+ <p class="publication-card__authors">{{ formattedAuthors }}</p>
18
+ <p class="publication-card__abstract" v-if="publication.abstract">{{ truncatedAbstract }}</p>
19
+ <div class="publication-card__footer">
20
+ <div class="publication-card__keywords">
21
+ <span
22
+ v-for="(keyword, index) in displayedKeywords"
23
+ :key="index"
24
+ class="publication-card__keyword"
25
+ >
26
+ {{ keyword }}
27
+ </span>
28
+ <span v-if="moreKeywords" class="publication-card__keyword-more">+{{ moreKeywords }}</span>
29
+ </div>
30
+ <!-- Only show DOI link if there's NO detail page -->
31
+ <a
32
+ v-if="publication.doi && !publication._path"
33
+ :href="publication.doi"
34
+ target="_blank"
35
+ rel="noopener"
36
+ class="publication-card__link"
37
+ :aria-label="t('publications.viewPublication')"
38
+ >
39
+ <LinkOut class="icon-inline" theme="outline" :size="16" fill="currentColor" :stroke-width="2" />
40
+ </a>
41
+ </div>
42
+ </article>
43
+ </template>
44
+
45
+ <script setup lang="ts">
46
+ import { computed } from 'vue'
47
+ import LinkOut from '@icon-park/vue-next/es/icons/LinkOut'
48
+
49
+ interface Publication {
50
+ title: string
51
+ authors: string[]
52
+ year: number
53
+ doi?: string
54
+ venue?: string
55
+ keywords?: string[]
56
+ abstract?: string
57
+ _path?: string
58
+ }
59
+
60
+ interface Props {
61
+ publication: Publication
62
+ }
63
+
64
+ const props = defineProps<Props>()
65
+
66
+ const { t } = useI18n()
67
+
68
+ const formattedAuthors = computed(() => {
69
+ const authors = props.publication.authors
70
+ if (authors.length <= 2) return authors.join(t('publications.authorSep'))
71
+ return authors.slice(0, authors.length - 1).join(', ') + ', ' + t('publications.authorSep') + authors[authors.length - 1]
72
+ })
73
+
74
+ const truncatedAbstract = computed(() => {
75
+ if (!props.publication.abstract) return ''
76
+ const abstract = props.publication.abstract
77
+ return abstract.length > 200 ? abstract.slice(0, 200) + t('publications.truncation') : abstract
78
+ })
79
+
80
+ const displayedKeywords = computed(() => {
81
+ if (!props.publication.keywords) return []
82
+ return props.publication.keywords.slice(0, 3)
83
+ })
84
+
85
+ const moreKeywords = computed(() => {
86
+ if (!props.publication.keywords) return 0
87
+ const remaining = props.publication.keywords.length - 3
88
+ return remaining > 0 ? t('publications.moreKeywords', { n: remaining }) : ''
89
+ })
90
+ </script>
91
+
92
+ <style scoped>
93
+ .publication-card {
94
+ background: var(--color-bg-alt);
95
+ border-radius: var(--radius-lg);
96
+ padding: var(--spacing-xl);
97
+ border: 1px solid var(--color-border);
98
+ box-shadow: var(--shadow-md);
99
+ transition: all var(--transition-base);
100
+ display: block;
101
+ text-decoration: none;
102
+ }
103
+
104
+ .publication-card:hover {
105
+ box-shadow: var(--shadow-lg);
106
+ border-color: var(--color-secondary);
107
+ transform: translateY(-4px);
108
+ }
109
+
110
+ .publication-card__meta {
111
+ display: flex;
112
+ align-items: center;
113
+ gap: var(--spacing-sm);
114
+ margin-bottom: var(--spacing-md);
115
+ }
116
+
117
+ .publication-card__year {
118
+ font-family: var(--font-display);
119
+ font-size: 0.875rem;
120
+ font-weight: 600;
121
+ color: var(--color-text-muted);
122
+ letter-spacing: 0.05em;
123
+ }
124
+
125
+ .badge {
126
+ display: inline-flex;
127
+ align-items: center;
128
+ padding: var(--spacing-xs) var(--spacing-sm);
129
+ font-size: 0.75rem;
130
+ font-weight: 600;
131
+ letter-spacing: 0.08em;
132
+ text-transform: uppercase;
133
+ border-radius: var(--radius-sm);
134
+ }
135
+
136
+ .badge-accent {
137
+ background: var(--color-accent);
138
+ color: white;
139
+ }
140
+
141
+ .publication-card__title {
142
+ font-family: var(--font-display);
143
+ font-size: 1.125rem;
144
+ font-weight: 600;
145
+ line-height: 1.3;
146
+ color: var(--color-primary);
147
+ margin-bottom: var(--spacing-sm);
148
+ }
149
+
150
+ .publication-card__title-link {
151
+ color: var(--color-secondary);
152
+ text-decoration: none;
153
+ transition: color var(--transition-fast);
154
+ display: block;
155
+ }
156
+
157
+ .publication-card__title-link:hover {
158
+ color: var(--color-accent);
159
+ text-decoration: underline;
160
+ }
161
+
162
+ .publication-card__title-text {
163
+ color: var(--color-primary);
164
+ }
165
+
166
+ .publication-card__authors {
167
+ font-size: 0.9375rem;
168
+ color: var(--color-text-muted);
169
+ line-height: 1.5;
170
+ margin-bottom: var(--spacing-md);
171
+ }
172
+
173
+ .publication-card__abstract {
174
+ font-family: var(--font-body);
175
+ font-size: 0.9375rem;
176
+ color: var(--color-text);
177
+ line-height: 1.6;
178
+ }
179
+
180
+ .publication-card__footer {
181
+ display: flex;
182
+ flex-direction: column;
183
+ gap: var(--spacing-sm);
184
+ margin-top: var(--spacing-lg);
185
+ }
186
+
187
+ .publication-card__keywords {
188
+ display: flex;
189
+ flex-wrap: wrap;
190
+ gap: var(--spacing-sm);
191
+ }
192
+
193
+ .publication-card__keyword {
194
+ display: inline-flex;
195
+ padding: var(--spacing-xs) var(--spacing-md);
196
+ font-size: 0.8125rem;
197
+ font-weight: 500;
198
+ color: var(--color-text);
199
+ background: var(--color-bg);
200
+ border: 1px solid var(--color-border);
201
+ border-radius: var(--radius-md);
202
+ transition: all var(--transition-fast);
203
+ }
204
+
205
+ .publication-card__keyword:hover {
206
+ background: var(--color-secondary);
207
+ color: white;
208
+ }
209
+
210
+ .publication-card__keyword-more {
211
+ display: inline-flex;
212
+ align-items: center;
213
+ padding: var(--spacing-xs) var(--spacing-md);
214
+ font-size: 0.8125rem;
215
+ font-weight: 500;
216
+ color: var(--color-text);
217
+ background: var(--color-bg);
218
+ border: 1px solid var(--color-border);
219
+ border-radius: var(--radius-md);
220
+ transition: all var(--transition-fast);
221
+ }
222
+
223
+ .publication-card__keyword-more:hover {
224
+ background: var(--color-secondary);
225
+ color: white;
226
+ }
227
+
228
+ .publication-card__link {
229
+ display: flex;
230
+ align-items: center;
231
+ justify-content: center;
232
+ width: 36px;
233
+ height: 36px;
234
+ background: var(--color-bg-alt);
235
+ border-radius: var(--radius-full);
236
+ color: var(--color-primary);
237
+ transition: all var(--transition-fast);
238
+ }
239
+
240
+ .publication-card__link:hover {
241
+ background: var(--color-secondary);
242
+ color: white;
243
+ transform: scale(1.1);
244
+ }
245
+ </style>
@@ -0,0 +1,75 @@
1
+ <template>
2
+ <div class="section-title" :class="`section-title--${align}`">
3
+ <span class="section-title__overline" v-if="overline">{{ overline }}</span>
4
+ <h2 class="section-title__title">{{ title }}</h2>
5
+ <p class="section-title__description" v-if="description">
6
+ {{ description }}
7
+ </p>
8
+ <div class="section-title__line"></div>
9
+ </div>
10
+ </template>
11
+
12
+ <script setup lang="ts">
13
+ interface Props {
14
+ title: string
15
+ overline?: string
16
+ description?: string
17
+ align?: 'left' | 'center'
18
+ }
19
+
20
+ withDefaults(defineProps<Props>(), {
21
+ align: 'left'
22
+ })
23
+ </script>
24
+
25
+ <style scoped>
26
+ .section-title {
27
+ margin-bottom: var(--spacing-2xl);
28
+ }
29
+
30
+ .section-title--center {
31
+ text-align: center;
32
+ }
33
+
34
+ .section-title--center .section-title__line {
35
+ margin-left: auto;
36
+ margin-right: auto;
37
+ }
38
+
39
+ .section-title__overline {
40
+ display: inline-block;
41
+ font-size: 0.75rem;
42
+ font-weight: 600;
43
+ letter-spacing: 0.1em;
44
+ text-transform: uppercase;
45
+ color: var(--color-secondary);
46
+ margin-bottom: var(--spacing-sm);
47
+ }
48
+
49
+ .section-title__title {
50
+ font-family: var(--font-display);
51
+ font-size: clamp(2rem, 4vw, 2.5rem);
52
+ font-weight: 700;
53
+ color: var(--color-primary);
54
+ margin-bottom: var(--spacing-sm);
55
+ }
56
+
57
+ .section-title__description {
58
+ font-size: 1.125rem;
59
+ color: var(--color-text-muted);
60
+ max-width: 600px;
61
+ }
62
+
63
+ .section-title--center .section-title__description {
64
+ margin-left: auto;
65
+ margin-right: auto;
66
+ }
67
+
68
+ .section-title__line {
69
+ width: 60px;
70
+ height: 4px;
71
+ background: linear-gradient(90deg, var(--color-accent), var(--color-secondary));
72
+ border-radius: 2px;
73
+ margin-top: var(--spacing-md);
74
+ }
75
+ </style>
@@ -0,0 +1,29 @@
1
+ <template>
2
+ <img :src="refinedSrc" :alt="alt" :width="width" :height="height" />
3
+ </template>
4
+
5
+ <script setup lang="ts">
6
+ const props = defineProps({
7
+ src: { type: String, default: '' },
8
+ alt: { type: String, default: '' },
9
+ width: { type: [String, Number], default: undefined },
10
+ height: { type: [String, Number], default: undefined }
11
+ })
12
+
13
+ const contentId = inject<Ref<string>>('contentId', ref(''))
14
+
15
+ const config = useRuntimeConfig()
16
+
17
+ const refinedSrc = computed(() => {
18
+ try {
19
+ const resolved = resolveContentImage(props.src, unref(contentId))
20
+ if (!resolved) return ''
21
+ const basePath = config.app.baseURL || ''
22
+ if (!basePath || basePath === '/') return resolved
23
+ return basePath.replace(/\/$/, '') + resolved
24
+ } catch {
25
+ // Fallback: return raw src so the page doesn't break
26
+ return props.src || ''
27
+ }
28
+ })
29
+ </script>
@@ -0,0 +1,58 @@
1
+ <template>
2
+ <!-- Mermaid blocks are diagrams, not code: hand off to the renderer. -->
3
+ <MermaidDiagram v-if="language === 'mermaid'" :code="code" />
4
+
5
+ <!-- Everything else: a highlighted code block with chrome. -->
6
+ <div v-else class="code-block">
7
+ <div class="code-block__header">
8
+ <span class="code-block__lang">{{ displayLanguage }}</span>
9
+ <button
10
+ type="button"
11
+ class="code-block__copy"
12
+ :class="{ 'is-copied': copied }"
13
+ :aria-label="copied ? t('code.copied') : t('code.copyCode')"
14
+ :title="copied ? t('code.copiedExclaim') : t('code.copy')"
15
+ @click="copyCode"
16
+ >
17
+ <Check v-if="copied" theme="outline" :size="16" :stroke-width="3" />
18
+ <Copy v-else theme="outline" :size="16" :stroke-width="3" />
19
+ </button>
20
+ </div>
21
+ <pre :class="$props.class"><slot /></pre>
22
+ </div>
23
+ </template>
24
+
25
+ <script setup lang="ts">
26
+ import Copy from '@icon-park/vue-next/es/icons/Copy'
27
+ import Check from '@icon-park/vue-next/es/icons/Check'
28
+
29
+ const { t } = useI18n()
30
+
31
+ const props = defineProps({
32
+ code: { type: String, default: '' },
33
+ language: { type: String, default: null },
34
+ filename: { type: String, default: null },
35
+ highlights: { type: Array as () => number[], default: () => [] },
36
+ meta: { type: String, default: null },
37
+ class: { type: String, default: null }
38
+ })
39
+
40
+ const copied = ref(false)
41
+
42
+ // Friendly label for the header; fall back to "text" for fenced blocks
43
+ // that declared no language.
44
+ const displayLanguage = computed(() => {
45
+ if (props.filename) return props.filename
46
+ return props.language || 'text'
47
+ })
48
+
49
+ async function copyCode() {
50
+ try {
51
+ await navigator.clipboard.writeText(props.code)
52
+ copied.value = true
53
+ setTimeout(() => { copied.value = false }, 2000)
54
+ } catch (err) {
55
+ console.error('[ProsePre] copy failed:', err)
56
+ }
57
+ }
58
+ </script>
@@ -0,0 +1,45 @@
1
+ <template>
2
+ <video
3
+ :src="refinedSrc"
4
+ :controls="controls"
5
+ :autoplay="autoplay"
6
+ :loop="loop"
7
+ :muted="muted"
8
+ :poster="refinedPoster"
9
+ :width="width"
10
+ :height="height"
11
+ >
12
+ <slot />
13
+ </video>
14
+ </template>
15
+
16
+ <script setup lang="ts">
17
+ import { inject, computed, type Ref } from 'vue'
18
+
19
+ const props = defineProps({
20
+ src: { type: String, default: '' },
21
+ poster: { type: String, default: undefined },
22
+ controls: { type: [Boolean, String], default: true },
23
+ autoplay: { type: [Boolean, String], default: false },
24
+ loop: { type: [Boolean, String], default: false },
25
+ muted: { type: [Boolean, String], default: false },
26
+ width: { type: [String, Number], default: undefined },
27
+ height: { type: [String, Number], default: undefined }
28
+ })
29
+
30
+ // Injected by the page component via provide('contentId', ...)
31
+ const contentId = inject<Ref<string>>('contentId', { value: '' } as Ref<string>)
32
+
33
+ const config = useRuntimeConfig()
34
+
35
+ function resolve(src?: string): string | undefined {
36
+ if (!src) return src
37
+ const resolved = resolveContentImage(src, contentId.value)
38
+ const basePath = config.app.baseURL || '/'
39
+ if (!basePath || basePath === '/' || resolved.startsWith(basePath)) return resolved
40
+ return basePath.replace(/\/$/, '') + resolved
41
+ }
42
+
43
+ const refinedSrc = computed(() => resolve(props.src))
44
+ const refinedPoster = computed(() => resolve(props.poster))
45
+ </script>
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Resolve a relative image path from content frontmatter to an absolute /_markuxt/ URL.
3
+ *
4
+ * Authors write `image: photo.webp` in frontmatter; this function converts it
5
+ * to `/_markuxt/members/staff/photo.webp` based on the source file location.
6
+ *
7
+ * Supports `../` for cross-directory references (e.g. shared placeholders).
8
+ * Absolute paths (`/...`, `http...`) pass through unchanged.
9
+ */
10
+ export function resolveContentImage(
11
+ image: string | undefined,
12
+ contentId: string | undefined,
13
+ ): string {
14
+ if (!image) return ''
15
+ if (image.startsWith('/') || image.startsWith('http') || image.startsWith('//')) {
16
+ return image
17
+ }
18
+ if (!contentId) return image
19
+
20
+ // _id format in Nuxt Content v2: 'content:members:staff:salman-ijaz.md'
21
+ // Split by ':', drop 'content' prefix and filename, rejoin as directory path
22
+ const parts = contentId.split(':')
23
+ if (parts.length < 3) return image
24
+
25
+ const dirSegments = parts.slice(1, -1) // e.g. ['members', 'staff']
26
+
27
+ // Normalize: resolve ../ and ./
28
+ const resolved = [...dirSegments]
29
+ for (const seg of image.split('/')) {
30
+ if (seg === '..') resolved.pop()
31
+ else if (seg !== '.') resolved.push(seg)
32
+ }
33
+
34
+ return '/_markuxt/' + resolved.join('/')
35
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Nuxt Content transformer for binary asset files (images, videos, etc.).
3
+ *
4
+ * Without this, @nuxt/content warns for every binary file in content/:
5
+ * ".webp files are not supported … falling back to raw content"
6
+ *
7
+ * This transformer claims those extensions so the built-in parser never
8
+ * falls through to the warning path.
9
+ */
10
+ export default {
11
+ name: 'binary-assets',
12
+ extensions: [
13
+ '\\.png', '\\.jpe?g', '\\.gif', '\\.webp', '\\.svg', '\\.ico',
14
+ '\\.mp4', '\\.webm', '\\.avi', '\\.mov', '\\.mkv',
15
+ '\\.pdf', '\\.zip', '\\.tar', '\\.gz',
16
+ '\\.woff2?', '\\.ttf', '\\.eot',
17
+ '\\.mp3', '\\.wav', '\\.ogg', '\\.flac',
18
+ ],
19
+ parse: async (_id: string, content: string) => ({ _id, _type: 'binary', body: content }),
20
+ }
package/src/error.vue ADDED
@@ -0,0 +1,58 @@
1
+ <template>
2
+ <div class="error-page">
3
+ <div class="error-page__content">
4
+ <h1 class="error-page__code">404</h1>
5
+ <h2 class="error-page__title">{{ t('error.notFound') }}</h2>
6
+ <p class="error-page__description">{{ t('error.notFoundDesc') }}</p>
7
+ <NuxtLink to="/" class="btn btn-primary">{{ t('error.backHome') }}</NuxtLink>
8
+ </div>
9
+ </div>
10
+ </template>
11
+
12
+ <script setup lang="ts">
13
+ const { t } = useI18n()
14
+
15
+ useHead({
16
+ title: '404 - Page Not Found'
17
+ })
18
+ </script>
19
+
20
+ <style scoped>
21
+ .error-page {
22
+ display: flex;
23
+ align-items: center;
24
+ justify-content: center;
25
+ padding: var(--spacing-3xl) var(--spacing-lg);
26
+ text-align: center;
27
+ min-height: 50vh;
28
+ }
29
+
30
+ .error-page__content {
31
+ max-width: 480px;
32
+ }
33
+
34
+ .error-page__code {
35
+ font-family: var(--font-display);
36
+ font-size: 8rem;
37
+ font-weight: 900;
38
+ line-height: 1;
39
+ color: var(--color-secondary);
40
+ margin-bottom: var(--spacing-md);
41
+ opacity: 0.3;
42
+ }
43
+
44
+ .error-page__title {
45
+ font-family: var(--font-display);
46
+ font-size: 1.5rem;
47
+ font-weight: 600;
48
+ color: var(--color-primary);
49
+ margin-bottom: var(--spacing-md);
50
+ }
51
+
52
+ .error-page__description {
53
+ font-size: 1rem;
54
+ color: var(--color-text-muted);
55
+ line-height: 1.6;
56
+ margin-bottom: var(--spacing-2xl);
57
+ }
58
+ </style>
@@ -0,0 +1,37 @@
1
+ <template>
2
+ <div class="layout">
3
+ <AppHeader />
4
+ <main class="main">
5
+ <slot />
6
+ </main>
7
+ <AppFooter />
8
+ </div>
9
+ </template>
10
+
11
+ <script setup lang="ts">
12
+ const { t, locale } = useI18n()
13
+
14
+ useHead({
15
+ htmlAttrs: { lang: locale },
16
+ title: t('site.title'),
17
+ meta: [
18
+ { name: 'description', content: t('site.description') },
19
+ { name: 'keywords', content: t('site.keywords') },
20
+ { property: 'og:title', content: t('site.ogTitle') },
21
+ { property: 'og:description', content: t('site.ogDescription') },
22
+ { property: 'og:type', content: 'website' },
23
+ ],
24
+ })
25
+ </script>
26
+
27
+ <style scoped>
28
+ .layout {
29
+ display: flex;
30
+ flex-direction: column;
31
+ min-height: 100vh;
32
+ }
33
+
34
+ .main {
35
+ flex: 1;
36
+ }
37
+ </style>
@@ -0,0 +1,22 @@
1
+ import type { Router } from 'vue-router'
2
+
3
+ export default defineNuxtPlugin(() => {
4
+ const appConfig = useAppConfig()
5
+ const allowedPaths = new Set(
6
+ (appConfig.markuxt?.navigation || []).map((item: { to: string }) => item.to)
7
+ )
8
+
9
+ addRouteMiddleware('navigation-guard', (to) => {
10
+ // Only guard markuxt section pages (members, publications, projects, positions, news)
11
+ const markuxtSections = ['/members', '/publications', '/projects', '/positions', '/news']
12
+ const isMarkuxtPage = markuxtSections.some(section => to.path.startsWith(section))
13
+
14
+ if (isMarkuxtPage) {
15
+ // Check if any allowed path matches this route's section
16
+ const sectionPath = '/' + to.path.split('/')[1]
17
+ if (!allowedPaths.has(sectionPath)) {
18
+ return abortNavigation(createError({ statusCode: 404, statusMessage: 'Page Not Found' }))
19
+ }
20
+ }
21
+ }, { global: true })
22
+ })