@eox/pages-theme-eox 2.0.0-alpha.1 → 2.0.0-alpha.10

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 (47) hide show
  1. package/.gitlab-ci.yml +4 -1
  2. package/README.md +75 -11
  3. package/cypress/support/mocks/authors.data.js +14 -0
  4. package/cypress/support/mocks/helpers.js +65 -4
  5. package/cypress.config.js +9 -0
  6. package/package.json +3 -1
  7. package/src/Layout.vue +14 -3
  8. package/src/components/BlogList.vue +74 -55
  9. package/src/components/BlogPost.vue +46 -24
  10. package/src/components/CTASection.vue +18 -4
  11. package/src/components/FeatureCard/Index.vue +113 -26
  12. package/src/components/FeatureCard/Poster.vue +25 -3
  13. package/src/components/FeatureSection.cy.js +16 -0
  14. package/src/components/FeatureSection.vue +3 -8
  15. package/src/components/FeaturesGallery.vue +34 -2
  16. package/src/components/Footer.cy.js +52 -2
  17. package/src/components/Footer.vue +17 -5
  18. package/src/components/HeroSection.cy.js +20 -0
  19. package/src/components/HeroSection.vue +139 -24
  20. package/src/components/ImageCarousel.vue +11 -2
  21. package/src/components/ImageElement.vue +16 -4
  22. package/src/components/ImageGallery.vue +29 -4
  23. package/src/components/LogoSection.vue +26 -3
  24. package/src/components/MobileNavDropdown.vue +13 -3
  25. package/src/components/NavBar.cy.js +15 -0
  26. package/src/components/NavBar.vue +149 -21
  27. package/src/components/NavDropdown.vue +12 -2
  28. package/src/components/OpenPositions.vue +292 -0
  29. package/src/components/TagList.vue +32 -14
  30. package/src/components/TeamPage.vue +34 -20
  31. package/src/components/TestimonialsSection.cy.js +2 -5
  32. package/src/components/TestimonialsSection.vue +119 -16
  33. package/src/components/TextSection.cy.js +115 -0
  34. package/src/components/TextSection.vue +396 -0
  35. package/src/helpers.js +182 -37
  36. package/src/helpers.test.js +181 -0
  37. package/src/index.js +4 -0
  38. package/src/pages/author/[author].md +1 -0
  39. package/src/pages/author/[author].paths.js +2 -2
  40. package/src/pages/blog.md +1 -0
  41. package/src/pages/tag/[tag].md +1 -0
  42. package/src/pages/tag.md +1 -0
  43. package/src/pages/team.md +2 -1
  44. package/src/posts.data.js +39 -1
  45. package/src/style.css +232 -11
  46. package/src/tagUtils.js +5 -5
  47. package/src/vitepressConfig.mjs +6 -3
package/.gitlab-ci.yml CHANGED
@@ -10,9 +10,11 @@ cache:
10
10
  - package-lock.json
11
11
  paths:
12
12
  - .npm/
13
+ - .cache/Cypress
13
14
 
14
15
  variables:
15
16
  npm_config_cache: "$CI_PROJECT_DIR/.npm"
17
+ CYPRESS_CACHE_FOLDER: "$CI_PROJECT_DIR/.cache/Cypress"
16
18
 
17
19
  security:
18
20
  stage: security
@@ -33,6 +35,7 @@ test:
33
35
  stage: test
34
36
  script:
35
37
  - npm ci
36
- - npm run test:component
38
+ - npx cypress install
39
+ - npm run test
37
40
  rules:
38
41
  - if: $CI_PIPELINE_SOURCE == 'merge_request_event' && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == 'main'
package/README.md CHANGED
@@ -145,26 +145,40 @@ The theme provides several built-in components and layout features. Some are dri
145
145
 
146
146
  ### Hero Section
147
147
 
148
- To add a Hero section to the top of a page, use the `hero` frontmatter object.
148
+ To add a Hero section to the top of a page, use the `hero` frontmatter object. Heros feature a dark gradient transitioning from **DARK (top) to LIGHT (bottom)** (`.hero-gradient-bg`), allowing the top navigation bar to seamlessly sit transparently over the hero. On the landing page, the hero is larger and incorporates an isometric line pattern.
149
149
 
150
150
  ```yaml
151
151
  ---
152
152
  hero:
153
- title: "Welcome to My Site"
154
- tagline: "Building the future of things"
153
+ text: "Welcome to My Site" # or title: "Welcome to My Site"
154
+ tagline: "Building the future of geospatial engineering"
155
155
  image:
156
156
  src: "/hero-image.png"
157
157
  alt: "Hero Image"
158
158
  actions:
159
159
  - text: "Get Started"
160
160
  link: "/start"
161
- type: "primary"
161
+ theme: "brand" # "brand" (primary surface), "secondary", or "alt" (bordered)
162
162
  - text: "Learn More"
163
163
  link: "/about"
164
- type: "secondary"
164
+ theme: "secondary"
165
165
  ---
166
166
  ```
167
167
 
168
+ #### Hero on Aggregator Pages (`blog`, `team`, `tags`)
169
+
170
+ Pages using aggregator layouts (`layout: team`, `layout: blog`, or `layout: tags`) support optional heroes:
171
+
172
+ - **Without Hero (when `hero` is omitted or `hero: false`)**: The page renders a standard in-page header title beneath the solid top navigation bar.
173
+ - **With Hero (`hero: true` or custom `hero: { text, tagline, ... }`)**: The page renders the full dark hero with the `#000C14` → `#004170` gradient spanning seamlessly behind the transparent navigation bar. Setting `hero: true` automatically supplies appropriate layout titles (including dynamic `Tag: <tag>` and `Author: <author>` for filtered blog listings). Custom text, taglines, images, and actions can also be provided via the `hero` frontmatter object.
174
+
175
+ #### Gradient Design Standards
176
+
177
+ The theme standardizes two distinct gradient directions for dark visual elements using `#000C14` and `#004170`:
178
+
179
+ - **Hero Gradient (`.hero-gradient-bg`)**: Always transitions from **DARK (top) to LIGHT (bottom)**, running from `#000C14` (top) to `#004170` (bottom). Used for the landing page hero and all sub-page heroes. This anchors the fixed header area with dark contrast under navigation links while flowing naturally into lighter content below.
180
+ - **Dark Sections Gradient (`.primary-gradient-bg` / `.dark-section-gradient-bg`)**: Used in intermediate dark page sections further down the page (such as `<CTASection dark>`, feature highlights, or dark showcase galleries). Always transitions from **LIGHT (top) to DARK (bottom)**, running from `#004170` (top) to `#000C14` (bottom).
181
+
168
182
  ### Feature Section
169
183
 
170
184
  Use the `<FeatureSection>` component to highlight specific features.
@@ -223,7 +237,7 @@ A flexible pricing table component.
223
237
 
224
238
  #### CTA Section
225
239
 
226
- A Call-to-Action section typically placed at the bottom of a page to encourage user engagement.
240
+ A Call-to-Action section typically placed at the bottom of a page to encourage user engagement. By default, it renders with a clean white (`surface-container-lowest`) background, or a dark gradient when the `dark` prop is passed.
227
241
 
228
242
  ```html
229
243
  <CTASection
@@ -334,7 +348,7 @@ A standard content card with an icon or image, title, optional chips, metadata,
334
348
  section-title="All Features"
335
349
  variant="default"
336
350
  columns="3/2/1"
337
- background="surface-container-low"
351
+ background="surface-container-lowest"
338
352
  :cards="[
339
353
  {
340
354
  id: 1,
@@ -572,7 +586,14 @@ feedback: false
572
586
  The theme exports a set of shared utility and helper functions from its helpers module. You can import and use these helpers in your custom VitePress components, checkout pages, or inline script setups:
573
587
 
574
588
  ```javascript
575
- import { formatPrice, trackEvent, t } from "@eox/pages-theme-eox/src/helpers";
589
+ import {
590
+ formatPrice,
591
+ trackEvent,
592
+ trackPageView,
593
+ trackSiteSearch,
594
+ normalizeTrackingUrl,
595
+ t,
596
+ } from "@eox/pages-theme-eox/src/helpers";
576
597
  ```
577
598
 
578
599
  ### Available Helpers
@@ -598,12 +619,12 @@ formatPrice(12000, "en"); // "€ 12,000.-"
598
619
  formatPrice(49.99, "de"); // "€ 49,99"
599
620
  ```
600
621
 
601
- #### `trackEvent(eventDetails)`
622
+ #### `trackEvent(eventDetails, ...rest)`
602
623
 
603
- Sends a custom tracking event to Matomo Analytics.
624
+ Sends a custom tracking event to Matomo Analytics. Automatically normalizes any path strings passed in category, action, or name.
604
625
 
605
626
  - **Parameters**:
606
- - `eventDetails` (`Array<string>`): An array representing the event payload: `[Category, Action, Name?, Value?]`.
627
+ - `eventDetails` (`Array<string|number>|string`): An array `[Category, Action, Name?, Value?]` or positional arguments `(category, action, name, value)`.
607
628
 
608
629
  ##### Example Usage:
609
630
 
@@ -611,8 +632,51 @@ Sends a custom tracking event to Matomo Analytics.
611
632
  import { trackEvent } from "@eox/pages-theme-eox/src/helpers";
612
633
 
613
634
  trackEvent(["CTA", "Click", "Technical Specifications", "Viewing Ready"]);
635
+ trackEvent("indicators", "select_indicator", "N1_NO2");
636
+ ```
637
+
638
+ #### `trackPageView(options)`
639
+
640
+ Tracks a pageview with normalized path (trailing slash stripped, transient parameters removed) and custom document title.
641
+
642
+ - **Parameters**:
643
+ - `options` (`Object|string`): Vitepress router object, `{ path, title }` options object, or custom path string.
644
+
645
+ ##### Example Usage:
646
+
647
+ ```javascript
648
+ import { trackPageView } from "@eox/pages-theme-eox/src/helpers";
649
+
650
+ trackPageView({ path: "/explore/", title: "Explore" });
651
+ // Sends normalized URL "/explore" to Matomo
614
652
  ```
615
653
 
654
+ #### `trackSiteSearch(keyword, category, resultsCount)`
655
+
656
+ Sends search keyword telemetry to Matomo Site Search reports.
657
+
658
+ - **Parameters**:
659
+ - `keyword` (`string`): Searched term.
660
+ - `category` (`string|boolean`): Category (optional, e.g. `'indicators'`, `'stories'`).
661
+ - `resultsCount` (`number`): Number of search results returned.
662
+
663
+ ##### Example Usage:
664
+
665
+ ```javascript
666
+ import { trackSiteSearch } from "@eox/pages-theme-eox/src/helpers";
667
+
668
+ trackSiteSearch("methane", "indicators", 4);
669
+ ```
670
+
671
+ #### `normalizeTrackingUrl(inputUrl, options)`
672
+
673
+ Pure helper that strips trailing slashes (e.g., `/explore/` -> `/explore`), strips volatile coordinate query params (`x`, `y`, `z`), and sorts remaining query parameters.
674
+
675
+ - **Parameters**:
676
+ - `inputUrl` (`string`): URL or path to normalize.
677
+ - `options` (`Object`): Optional configuration (`ignoredParams`, `stripTrailingSlash`).
678
+ - **Returns**: `string` - Normalized URL string.
679
+
616
680
  #### `t(key, i18n)`
617
681
 
618
682
  Retrieves localized translation strings from an i18n mapping object using dot-notation. Falls back to the key itself if no translation is defined.
@@ -0,0 +1,14 @@
1
+ export const data = [
2
+ {
3
+ id: "mock-author-1",
4
+ name: "Mock Author 1",
5
+ url: "/team/mock-author-1",
6
+ guest: false,
7
+ },
8
+ {
9
+ id: "mock-author-2",
10
+ name: "Mock Author 2",
11
+ url: "/team/mock-author-2",
12
+ guest: false,
13
+ },
14
+ ];
@@ -1,7 +1,40 @@
1
1
  // Mock generic helpers
2
2
  // We delegate to a global object to allow spying in tests
3
3
  window.__helpersMock = {
4
- trackEvent: (details) => {},
4
+ trackEvent: (details, ...rest) => {},
5
+ trackPageView: (options) => {},
6
+ trackSiteSearch: (keyword, category, count) => {},
7
+ normalizeTrackingUrl: (url, options) => {
8
+ if (!url || typeof url !== "string") return url;
9
+ const { ignoredParams = ["x", "y", "z"], stripTrailingSlash = true } =
10
+ options || {};
11
+ try {
12
+ const base =
13
+ typeof window !== "undefined" && window.location?.href
14
+ ? window.location.href
15
+ : "https://eox.at";
16
+ const u = new URL(url, base);
17
+ let pathname = u.pathname;
18
+ if (stripTrailingSlash && pathname.length > 1 && pathname.endsWith("/")) {
19
+ pathname = pathname.replace(/\/+$/, "");
20
+ }
21
+ const searchParams = new URLSearchParams(u.search);
22
+ for (const key of ignoredParams) {
23
+ searchParams.delete(key);
24
+ }
25
+ searchParams.sort();
26
+ const search = searchParams.toString();
27
+ const isRelative = !/^https?:\/\//i.test(url);
28
+ if (isRelative) {
29
+ return pathname + (search ? `?${search}` : "");
30
+ }
31
+ u.pathname = pathname;
32
+ u.search = search ? `?${search}` : "";
33
+ return u.toString();
34
+ } catch {
35
+ return url;
36
+ }
37
+ },
5
38
  acceptCookies: (router) => {},
6
39
  declineCookies: (router) => {},
7
40
  enableTracking: (enable, router) => {},
@@ -15,9 +48,31 @@ window.__helpersMock = {
15
48
  }
16
49
  },
17
50
  isActive: (item, path) => {
18
- if (item.link === path) return true;
19
- if (item.items) {
20
- return item.items.some((sub) => window.__helpersMock.isActive(sub, path));
51
+ if (!item || !path) return false;
52
+ if (item.activeMatch) {
53
+ try {
54
+ return new RegExp(item.activeMatch).test(path);
55
+ } catch (e) {
56
+ return false;
57
+ }
58
+ }
59
+ if (
60
+ item.items &&
61
+ item.items.some((sub) => window.__helpersMock.isActive(sub, path))
62
+ ) {
63
+ return true;
64
+ }
65
+ if (item.link) {
66
+ const normalize = (p) =>
67
+ decodeURI(p)
68
+ .replace(/[?#].*$/, "")
69
+ .replace(/\.html$/, "")
70
+ .replace(/\/$/, "");
71
+ const linkPath = normalize(item.link);
72
+ const currentPath = normalize(path);
73
+ if (linkPath === currentPath) return true;
74
+ if (linkPath === "") return currentPath === "";
75
+ return currentPath === linkPath || currentPath.startsWith(linkPath + "/");
21
76
  }
22
77
  return false;
23
78
  },
@@ -94,6 +149,12 @@ window.__helpersMock = {
94
149
  };
95
150
 
96
151
  export const trackEvent = (...args) => window.__helpersMock.trackEvent(...args);
152
+ export const trackPageView = (...args) =>
153
+ window.__helpersMock.trackPageView(...args);
154
+ export const trackSiteSearch = (...args) =>
155
+ window.__helpersMock.trackSiteSearch(...args);
156
+ export const normalizeTrackingUrl = (...args) =>
157
+ window.__helpersMock.normalizeTrackingUrl(...args);
97
158
  export const acceptCookies = (...args) =>
98
159
  window.__helpersMock.acceptCookies(...args);
99
160
  export const declineCookies = (...args) =>
package/cypress.config.js CHANGED
@@ -34,6 +34,15 @@ export default defineConfig({
34
34
  __dirname,
35
35
  "cypress/support/mocks/features.data.js",
36
36
  ),
37
+ // Alias authors.data.js to our mock
38
+ "../authors.data.js": path.resolve(
39
+ __dirname,
40
+ "cypress/support/mocks/authors.data.js",
41
+ ),
42
+ "../../authors.data.js": path.resolve(
43
+ __dirname,
44
+ "cypress/support/mocks/authors.data.js",
45
+ ),
37
46
  },
38
47
  },
39
48
  plugins: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eox/pages-theme-eox",
3
- "version": "2.0.0-alpha.1",
3
+ "version": "2.0.0-alpha.10",
4
4
  "type": "module",
5
5
  "description": "Vitepress Theme with EOX branding",
6
6
  "main": "src/index.js",
@@ -12,6 +12,8 @@
12
12
  "scripts": {
13
13
  "format": "npx prettier --write .",
14
14
  "format:check": "npx prettier --check .",
15
+ "test": "npm run test:unit && npm run test:component",
16
+ "test:unit": "node --test src/helpers.test.js",
15
17
  "test:component": "cypress run --component",
16
18
  "release": "release-it"
17
19
  },
package/src/Layout.vue CHANGED
@@ -1,22 +1,32 @@
1
1
  <template>
2
2
  <div
3
3
  v-if="frontmatter.layout === 'blog'"
4
- class="layout-blog min-h-screen flex flex-col"
4
+ class="Layout layout-blog min-h-screen flex flex-col"
5
5
  >
6
6
  <NavBar />
7
7
  <NewsBanner v-if="theme.newsBanner" :content="theme.newsBanner" />
8
+ <HeroSection />
8
9
  <main class="flex-grow">
9
10
  <BlogList />
11
+ <CTASection
12
+ v-if="frontmatter.cta !== false"
13
+ title="Interested in applying EO data to your use case?"
14
+ tagline="Let's build cloud-native systems, interactive maps, or customized Sentinel data layers together. Reach out to discuss your project requirements."
15
+ primaryButton="Contact us"
16
+ primaryLink="/contact"
17
+ dark
18
+ />
10
19
  </main>
11
20
  <Footer v-if="frontmatter.footer !== false" />
12
21
  <CookieBanner v-if="theme.theme.brandConfig?.analytics"></CookieBanner>
13
22
  </div>
14
23
  <div
15
24
  v-else-if="frontmatter.layout === 'tags'"
16
- class="layout-tags min-h-screen flex flex-col"
25
+ class="Layout layout-tags min-h-screen flex flex-col"
17
26
  >
18
27
  <NavBar />
19
28
  <NewsBanner v-if="theme.newsBanner" :content="theme.newsBanner" />
29
+ <HeroSection />
20
30
  <main class="flex-grow">
21
31
  <TagList />
22
32
  </main>
@@ -25,10 +35,11 @@
25
35
  </div>
26
36
  <div
27
37
  v-else-if="frontmatter.layout === 'team'"
28
- class="layout-team min-h-screen flex flex-col"
38
+ class="Layout layout-team min-h-screen flex flex-col"
29
39
  >
30
40
  <NavBar />
31
41
  <NewsBanner v-if="theme.newsBanner" :content="theme.newsBanner" />
42
+ <HeroSection />
32
43
  <main class="flex-grow">
33
44
  <TeamPage />
34
45
  </main>
@@ -1,45 +1,40 @@
1
1
  <template>
2
- <div class="blog-landing max-w-[1200px] mx-auto px-6 md:px-8">
3
- <!-- Page Header -->
4
- <div class="blog-landing-header" role="banner">
2
+ <div
3
+ class="blog-landing max-w-[1200px] mx-auto px-6 md:px-8"
4
+ :class="{ 'has-hero': Boolean(frontmatter.hero) }"
5
+ >
6
+ <!-- Page Header (shown only when hero is not present) -->
7
+ <div v-if="!frontmatter.hero" class="blog-landing-header" role="banner">
5
8
  <div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
6
9
  <div>
7
- <h1 class="blog-landing-title">
8
- EOX Blog
9
- </h1>
10
+ <h1 class="blog-landing-title">EOX Blog</h1>
10
11
  <p class="blog-landing-subtitle">
11
12
  Insights, updates, and deep dives from our geospatial experts.
12
13
  </p>
13
14
  </div>
14
15
  </div>
16
+ </div>
15
17
 
16
- <!-- Active Filter Banner -->
17
- <div
18
- v-if="activeTag || activeAuthor"
19
- class="active-filter-banner"
18
+ <!-- Active Filter Banner -->
19
+ <div v-if="activeTag || activeAuthor" class="active-filter-banner mb-6">
20
+ <span class="filter-label">Active filter:</span>
21
+ <span
22
+ v-if="activeTag"
23
+ :style="getTagStyle(activeTag)"
24
+ class="active-filter-tag shadow-sm inline-flex items-center gap-1.5"
20
25
  >
21
- <span class="filter-label">Active filter:</span>
22
- <span
23
- v-if="activeTag"
24
- :style="getTagStyle(activeTag)"
25
- class="active-filter-tag shadow-sm inline-flex items-center gap-1.5"
26
- >
27
- {{ activeTag }}
28
- </span>
29
- <span
30
- v-if="activeAuthor"
31
- class="active-filter-author shadow-sm inline-flex items-center gap-1.5"
32
- >
33
- Author: {{ getAuthorName(activeAuthor) }}
34
- </span>
35
- <button
36
- @click="clearFilters"
37
- class="clear-filter-btn"
38
- >
39
- <i class="mdi mdi-close-circle"></i>
40
- <span>Clear Filter</span>
41
- </button>
42
- </div>
26
+ {{ activeTag }}
27
+ </span>
28
+ <span
29
+ v-if="activeAuthor"
30
+ class="active-filter-author shadow-sm inline-flex items-center gap-1.5"
31
+ >
32
+ Author: {{ getAuthorName(activeAuthor) }}
33
+ </span>
34
+ <button @click="clearFilters" class="clear-filter-btn">
35
+ <i class="mdi mdi-close-circle"></i>
36
+ <span>Clear Filter</span>
37
+ </button>
43
38
  </div>
44
39
 
45
40
  <!-- Grid of Blog Posts -->
@@ -52,14 +47,23 @@
52
47
  :key="post.url"
53
48
  :title="post.title"
54
49
  :content="post.excerpt"
55
- :image="post.coverImage ? getCoverImageUrl(post.coverImage) : undefined"
50
+ :image="
51
+ post.coverImage ? getCoverImageUrl(post.coverImage) : undefined
52
+ "
56
53
  :date="post.date"
57
54
  :authors="post.authors"
58
- :chips="post.tags ? post.tags.map(t => ({ text: t })) : []"
55
+ :chips="
56
+ post.tags
57
+ ? post.tags.map((t) => ({
58
+ text: t,
59
+ link: withBase(`/tag/${t}/`),
60
+ }))
61
+ : []
62
+ "
59
63
  :link="{
60
64
  text: 'Read more',
61
- href: post.url,
62
- target: '_self'
65
+ href: withBase(post.url),
66
+ target: '_self',
63
67
  }"
64
68
  variant="cover"
65
69
  class="h-full bg-surface-container-low"
@@ -87,18 +91,18 @@
87
91
  <!-- Pagination -->
88
92
  <nav
89
93
  v-if="totalPages > 1"
90
- class="mt-16 flex justify-center items-center gap-2"
94
+ class="mt-16 flex justify-center items-center gap-1 sm:gap-2"
91
95
  >
92
96
  <button
93
97
  @click="changePage(currentPage - 1)"
94
98
  :disabled="currentPage === 1"
95
- class="p-2.5 rounded-xl border border-slate-200 text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-transparent disabled:cursor-not-allowed transition-colors"
99
+ class="min-w-[32px] sm:min-w-[40px] h-[32px] sm:h-[40px] flex items-center justify-center rounded-xl border border-slate-200 text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-transparent disabled:cursor-not-allowed transition-colors"
96
100
  >
97
101
  <span class="sr-only">Previous</span>
98
102
  <i class="mdi mdi-chevron-left text-lg"></i>
99
103
  </button>
100
104
 
101
- <div class="flex items-center gap-1.5">
105
+ <div class="flex items-center gap-1 sm:gap-1.5">
102
106
  <button
103
107
  v-for="page in visiblePages"
104
108
  :key="page"
@@ -108,7 +112,7 @@
108
112
  ? 'bg-primary text-white border-primary shadow-sm'
109
113
  : 'border-slate-200 text-slate-600 hover:bg-slate-50',
110
114
  ]"
111
- class="min-w-[40px] h-[40px] px-3 font-semibold text-sm rounded-xl border flex items-center justify-center transition-all duration-150"
115
+ class="min-w-[32px] sm:min-w-[40px] h-[32px] sm:h-[40px] px-2 sm:px-3 font-semibold text-sm rounded-xl border flex items-center justify-center transition-all duration-150"
112
116
  >
113
117
  {{ page }}
114
118
  </button>
@@ -117,7 +121,7 @@
117
121
  <button
118
122
  @click="changePage(currentPage + 1)"
119
123
  :disabled="currentPage === totalPages"
120
- class="p-2.5 rounded-xl border border-slate-200 text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-transparent disabled:cursor-not-allowed transition-colors"
124
+ class="min-w-[32px] sm:min-w-[40px] h-[32px] sm:h-[40px] flex items-center justify-center rounded-xl border border-slate-200 text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-transparent disabled:cursor-not-allowed transition-colors"
121
125
  >
122
126
  <span class="sr-only">Next</span>
123
127
  <i class="mdi mdi-chevron-right text-lg"></i>
@@ -129,9 +133,7 @@
129
133
  <aside class="blog-sidebar-col">
130
134
  <!-- Search widget -->
131
135
  <div class="sidebar-widget">
132
- <h3 class="sidebar-title">
133
- Search
134
- </h3>
136
+ <h3 class="sidebar-title">Search</h3>
135
137
  <div class="search-container">
136
138
  <VPNavBarSearch class="search" />
137
139
  </div>
@@ -140,10 +142,8 @@
140
142
  <!-- Hot Tags -->
141
143
  <div class="sidebar-widget">
142
144
  <div class="sidebar-title-container">
143
- <h3 class="sidebar-title-inline">
144
- Hot tags
145
- </h3>
146
- <a href="/tag" class="see-all-link">
145
+ <h3 class="sidebar-title-inline">Hot tags</h3>
146
+ <a :href="withBase('/tag')" class="see-all-link">
147
147
  <span>see all</span>
148
148
  <i class="mdi mdi-arrow-right"></i>
149
149
  </a>
@@ -152,7 +152,7 @@
152
152
  <a
153
153
  v-for="tag in calculatedHotTags"
154
154
  :key="tag.name"
155
- :href="`/tag/${tag.name}/`"
155
+ :href="withBase(`/tag/${tag.name}/`)"
156
156
  :style="getTagStyle(tag.name)"
157
157
  class="sidebar-tag"
158
158
  >
@@ -167,7 +167,7 @@
167
167
 
168
168
  <script setup>
169
169
  import { ref, computed, onMounted, watch } from "vue";
170
- import { useRouter, useData } from "vitepress";
170
+ import { useRouter, useData, withBase } from "vitepress";
171
171
  import { data as posts } from "../posts.data.js";
172
172
  import { data as authorsList } from "../authors.data.js";
173
173
  import { getTagColor, getTagStyle } from "../tagUtils.js";
@@ -198,7 +198,7 @@ const calculatedHotTags = computed(() => {
198
198
  });
199
199
 
200
200
  const router = useRouter();
201
- const { params } = useData();
201
+ const { params, frontmatter } = useData();
202
202
 
203
203
  const activeTag = ref("");
204
204
  const activeAuthor = ref("");
@@ -208,10 +208,17 @@ const postsPerPage = 9;
208
208
  // Form cover image URL correctly (prefixing filename with /images/)
209
209
  function getCoverImageUrl(coverImage) {
210
210
  if (!coverImage) return "";
211
- if (coverImage.startsWith("/") || coverImage.startsWith("http")) {
211
+ if (coverImage.startsWith("http") || coverImage.startsWith("//")) {
212
212
  return coverImage;
213
213
  }
214
- return `/images/${coverImage}`;
214
+ const base = withBase("/");
215
+ if (base && base !== "/" && coverImage.startsWith(base)) {
216
+ return coverImage;
217
+ }
218
+ const path = coverImage.startsWith("/")
219
+ ? coverImage
220
+ : `/images/${coverImage}`;
221
+ return withBase(path);
215
222
  }
216
223
 
217
224
  const tagFromParam = computed(() => {
@@ -341,6 +348,10 @@ function clearFilters() {
341
348
  padding-bottom: 48px !important;
342
349
  }
343
350
 
351
+ .blog-landing.has-hero {
352
+ padding-top: 2rem !important;
353
+ }
354
+
344
355
  @media (min-width: 640px) {
345
356
  .blog-landing {
346
357
  padding-left: 1.5rem !important;
@@ -348,6 +359,9 @@ function clearFilters() {
348
359
  padding-top: calc(var(--vp-nav-height) + 40px) !important;
349
360
  padding-bottom: 64px !important;
350
361
  }
362
+ .blog-landing.has-hero {
363
+ padding-top: 4.5rem !important;
364
+ }
351
365
  }
352
366
 
353
367
  @media (min-width: 960px) {
@@ -357,6 +371,9 @@ function clearFilters() {
357
371
  padding-top: calc(var(--vp-nav-height) + 58px) !important;
358
372
  padding-bottom: 96px !important;
359
373
  }
374
+ .blog-landing.has-hero {
375
+ padding-top: 8rem !important;
376
+ }
360
377
  }
361
378
 
362
379
  .blog-landing-header {
@@ -522,7 +539,9 @@ function clearFilters() {
522
539
  border-radius: 4px !important;
523
540
  text-decoration: none !important;
524
541
  line-height: 1.2 !important;
525
- transition: opacity 0.2s ease, transform 0.1s ease !important;
542
+ transition:
543
+ opacity 0.2s ease,
544
+ transform 0.1s ease !important;
526
545
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05) !important;
527
546
  }
528
547
 
@@ -584,7 +603,7 @@ function clearFilters() {
584
603
  padding: 0 !important;
585
604
  margin: 0 !important;
586
605
  margin-left: 0.5rem !important;
587
-
606
+
588
607
  display: inline-flex !important;
589
608
  align-items: center !important;
590
609
  gap: 0.375rem !important;