@eox/pages-theme-eox 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.gitlab-ci.yml CHANGED
@@ -1,6 +1,7 @@
1
1
  image: cypress/base:20.14.0
2
2
 
3
3
  stages:
4
+ - security
4
5
  - test
5
6
 
6
7
  cache:
@@ -13,6 +14,13 @@ cache:
13
14
  variables:
14
15
  npm_config_cache: "$CI_PROJECT_DIR/.npm"
15
16
 
17
+ security:
18
+ stage: security
19
+ image: node:24-alpine
20
+ script:
21
+ - npm ci
22
+ - npm audit --audit-level=critical
23
+
16
24
  format:
17
25
  stage: test
18
26
  script:
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ # [1.9.0](https://gitlab.eox.at/eox/hub/eoxhub-portal/compare/v1.8.0...v1.9.0) (2026-06-11)
4
+
5
+ ### Features
6
+
7
+ - new components (ImageCarousel, ImageElement, ImageGallery, SingleTweet) and automated loaders (team) ([8ce5f28](https://gitlab.eox.at/eox/hub/eoxhub-portal/commit/8ce5f289f06c1d4ebd7ff4871008f0dec31dabcc))
8
+
9
+ # [1.8.0](https://gitlab.eox.at/eox/hub/eoxhub-portal/compare/v1.7.0...v1.8.0) (2026-06-08)
10
+
11
+ ### Bug Fixes
12
+
13
+ - **style:** z-index for sidebar/footer ([6326866](https://gitlab.eox.at/eox/hub/eoxhub-portal/commit/632686679cd4db43a50e8e97b4557eb072409fd1))
14
+
15
+ ### Features
16
+
17
+ - price formatting helper ([287a28a](https://gitlab.eox.at/eox/hub/eoxhub-portal/commit/287a28abe3c8611d36685bae2efb75973e94d6c1))
18
+
3
19
  # [1.7.0](https://gitlab.eox.at/eox/hub/eoxhub-portal/compare/v1.6.0...v1.7.0) (2026-05-11)
4
20
 
5
21
  ### Bug Fixes
package/README.md CHANGED
@@ -379,6 +379,76 @@ An image-overlay card where a background image fills the card and text is render
379
379
  />
380
380
  ```
381
381
 
382
+ #### Image Element
383
+
384
+ Displays standard images with optional caption and width, and supports interactive side-by-side comparison slider when a `compare` prop is provided.
385
+
386
+ ```html
387
+ <!-- Standard Image -->
388
+ <ImageElement
389
+ src="/images/someimage.jpg"
390
+ caption="Standard view"
391
+ width="450px"
392
+ />
393
+
394
+ <!-- Image Comparison Slider -->
395
+ <ImageElement
396
+ src="/images/eoxcloudless2024.jpg"
397
+ compare="/images/eoxcloudless2023.jpg"
398
+ caption="2024 vs. 2023 comparison"
399
+ />
400
+ ```
401
+
402
+ #### Image Gallery
403
+
404
+ A unified, responsive image grid and lightbox gallery. Supports both plain link/grid layouts and touch-friendly lightbox galleries.
405
+
406
+ ##### Properties
407
+
408
+ | Prop | Type | Default | Description |
409
+ | :--------- | :-------------- | :---------- | :---------------------------------------------------------------------------------------------------- |
410
+ | `images` | `String\|Array` | — | Comma-separated image paths string, array of paths, or array of image objects with caption and links. |
411
+ | `columns` | `Number` | `2` | Number of columns (1, 2, 3, 4, 5). |
412
+ | `layout` | `String` | `"gallery"` | Layout mode: `"gallery"` (featured image + thumbnails) or `"grid"` (regular grid of images). |
413
+ | `lightbox` | `Boolean` | `true` | Enable fullscreen lightbox overlay when clicking an image. |
414
+
415
+ ```html
416
+ <!-- Gallery with Lightbox -->
417
+ <ImageGallery images="/img1.jpg, /img2.jpg, /img3.jpg" layout="gallery" />
418
+
419
+ <!-- Direct Image Grid with Captions & Links -->
420
+ <ImageGallery
421
+ layout="grid"
422
+ :columns="3"
423
+ :images="[
424
+ { src: '/img1.jpg', caption: 'Link to A', link: '/page-a' },
425
+ { src: '/img2.jpg', caption: 'Only Caption' },
426
+ { src: '/img3.jpg', caption: 'Link to B', link: '/page-b' }
427
+ ]"
428
+ />
429
+ ```
430
+
431
+ #### Image Carousel
432
+
433
+ A simple CSS-driven accessible image slideshow.
434
+
435
+ ```html
436
+ <ImageCarousel
437
+ :images="[
438
+ { src: '/slide1.jpg', caption: 'Slide 1 Caption' },
439
+ { src: '/slide2.jpg', caption: 'Slide 2 Caption' }
440
+ ]"
441
+ />
442
+ ```
443
+
444
+ #### Single Tweet
445
+
446
+ A privacy-respecting Twitter/X embed wrapper that checks Matomo cookie consent before rendering.
447
+
448
+ ```html
449
+ <SingleTweet id="1276442045352226817" />
450
+ ```
451
+
382
452
  #### Feedback Form
383
453
 
384
454
  **Feedback Form** for collecting user feedback can be enabled by configuring the theme:
@@ -483,3 +553,57 @@ feedback form can be disabled on individual pages via frontmatter configuration
483
553
  feedback: false
484
554
  ---
485
555
  ```
556
+
557
+ ## Helper Functions
558
+
559
+ 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:
560
+
561
+ ```javascript
562
+ import { formatPrice, trackEvent, t } from "@eox/pages-theme-eox/src/helpers";
563
+ ```
564
+
565
+ ### Available Helpers
566
+
567
+ #### `formatPrice(price, lang)`
568
+
569
+ Formats a numeric or string-based price following either British/American style or Continental European style based on the active language code.
570
+
571
+ - **Parameters**:
572
+ - `price` (`number|string`): The numeric price (or numeric string) to format.
573
+ - `lang` (`string`): The active language prefix (defaults to `'en'`).
574
+ - **Returns**: `string` - Formatted price string.
575
+ - **Behavior**:
576
+ - **English (`'en'`)**: Uses commas as thousands separators, dots as decimals, and adds a `.-` suffix for whole numbers (e.g., `1500` formatted to `€ 1,500.-`, `1500.50` formatted to `€ 1,500.50`).
577
+ - **Other languages (e.g., German/Continental)**: Uses dots as thousands separators, commas as decimals, and adds a `,-` suffix for whole numbers (e.g., `1500` formatted to `€ 1.500,-`, `1500.5` formatted to `€ 1.500,50`).
578
+
579
+ ##### Example Usage:
580
+
581
+ ```javascript
582
+ import { formatPrice } from "@eox/pages-theme-eox/src/helpers";
583
+
584
+ formatPrice(12000, "en"); // "€ 12,000.-"
585
+ formatPrice(49.99, "de"); // "€ 49,99"
586
+ ```
587
+
588
+ #### `trackEvent(eventDetails)`
589
+
590
+ Sends a custom tracking event to Matomo Analytics.
591
+
592
+ - **Parameters**:
593
+ - `eventDetails` (`Array<string>`): An array representing the event payload: `[Category, Action, Name?, Value?]`.
594
+
595
+ ##### Example Usage:
596
+
597
+ ```javascript
598
+ import { trackEvent } from "@eox/pages-theme-eox/src/helpers";
599
+
600
+ trackEvent(["CTA", "Click", "Technical Specifications", "Viewing Ready"]);
601
+ ```
602
+
603
+ #### `t(key, i18n)`
604
+
605
+ Retrieves localized translation strings from an i18n mapping object using dot-notation. Falls back to the key itself if no translation is defined.
606
+
607
+ - **Parameters**:
608
+ - `key` (`string`): The lookup key (supports dot notation, e.g., `'legal.privacy'`).
609
+ - `i18n` (`object`): The translation object/table.
@@ -47,6 +47,50 @@ window.__helpersMock = {
47
47
  }
48
48
  return result ? result : key;
49
49
  },
50
+ formatPrice: (price, lang = "en") => {
51
+ if (typeof price === "string") {
52
+ const trimmed = price.trim();
53
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
54
+ price = Number(trimmed);
55
+ } else {
56
+ return price;
57
+ }
58
+ }
59
+ if (typeof price === "number" && !isNaN(price)) {
60
+ const isEnglish = !lang || lang.startsWith("en");
61
+ const hasCents = price % 1 !== 0;
62
+ if (isEnglish) {
63
+ if (hasCents) {
64
+ const formatted = new Intl.NumberFormat("en-US", {
65
+ minimumFractionDigits: 2,
66
+ maximumFractionDigits: 2,
67
+ }).format(price);
68
+ return `€ ${formatted}`;
69
+ } else {
70
+ const formatted = new Intl.NumberFormat("en-US", {
71
+ minimumFractionDigits: 0,
72
+ maximumFractionDigits: 0,
73
+ }).format(price);
74
+ return `€ ${formatted}.-`;
75
+ }
76
+ } else {
77
+ if (hasCents) {
78
+ const formatted = new Intl.NumberFormat("de-DE", {
79
+ minimumFractionDigits: 2,
80
+ maximumFractionDigits: 2,
81
+ }).format(price);
82
+ return `€ ${formatted}`;
83
+ } else {
84
+ const formatted = new Intl.NumberFormat("de-DE", {
85
+ minimumFractionDigits: 0,
86
+ maximumFractionDigits: 0,
87
+ }).format(price);
88
+ return `€ ${formatted},-`;
89
+ }
90
+ }
91
+ }
92
+ return price;
93
+ },
50
94
  };
51
95
 
52
96
  export const trackEvent = (...args) => window.__helpersMock.trackEvent(...args);
@@ -61,3 +105,5 @@ export const isActive = (...args) => window.__helpersMock.isActive(...args);
61
105
  export const getFlatList = (...args) =>
62
106
  window.__helpersMock.getFlatList(...args);
63
107
  export const t = (...args) => window.__helpersMock.t(...args);
108
+ export const formatPrice = (...args) =>
109
+ window.__helpersMock.formatPrice(...args);
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@eox/pages-theme-eox",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "description": "Vitepress Theme with EOX branding",
6
6
  "main": "src/index.js",
7
7
  "author": "EOX",
8
8
  "license": "MIT",
9
+ "engines": {
10
+ "node": ">=24.0.0"
11
+ },
9
12
  "scripts": {
10
13
  "format": "npx prettier --write .",
11
14
  "format:check": "npx prettier --check .",
@@ -0,0 +1,22 @@
1
+ import ImageCarousel from "./ImageCarousel.vue";
2
+
3
+ describe("<ImageCarousel />", () => {
4
+ const images = [
5
+ { src: "/slide1.jpg", alt: "Slide 1", caption: "Caption 1" },
6
+ { src: "/slide2.jpg", alt: "Slide 2", caption: "Caption 2" },
7
+ ];
8
+
9
+ it("renders slider images, captions, and buttons", () => {
10
+ cy.mount(ImageCarousel, {
11
+ props: {
12
+ images,
13
+ },
14
+ });
15
+
16
+ cy.get("img").should("have.length", 2);
17
+ cy.get("img").eq(0).should("have.attr", "src", "/slide1.jpg");
18
+
19
+ // Indicators should exist
20
+ cy.get("button").should("have.length", 4); // 2 nav arrows + 2 indicators
21
+ });
22
+ });
@@ -0,0 +1,268 @@
1
+ <template>
2
+ <div class="image-carousel-container group">
3
+ <!-- Slider Container -->
4
+ <div
5
+ ref="container"
6
+ class="carousel-slider scrollbar-none"
7
+ @scroll="handleScroll"
8
+ >
9
+ <div
10
+ v-for="(img, idx) in normalizedImages"
11
+ :key="idx"
12
+ class="carousel-slide"
13
+ >
14
+ <img
15
+ :src="img.src"
16
+ :alt="img.alt || `Carousel image ${idx + 1}`"
17
+ class="carousel-img"
18
+ />
19
+ <div
20
+ v-if="img.caption"
21
+ class="carousel-caption"
22
+ v-html="img.caption"
23
+ ></div>
24
+ </div>
25
+ </div>
26
+
27
+ <!-- Navigation Arrows -->
28
+ <button
29
+ v-if="normalizedImages.length > 1"
30
+ @click="prev"
31
+ class="carousel-nav carousel-prev"
32
+ aria-label="Previous Slide"
33
+ >
34
+ <svg
35
+ class="carousel-icon"
36
+ viewBox="0 0 24 24"
37
+ fill="none"
38
+ stroke="currentColor"
39
+ stroke-width="2"
40
+ >
41
+ <path
42
+ stroke-linecap="round"
43
+ stroke-linejoin="round"
44
+ d="M15 19l-7-7 7-7"
45
+ />
46
+ </svg>
47
+ </button>
48
+
49
+ <button
50
+ v-if="normalizedImages.length > 1"
51
+ @click="next"
52
+ class="carousel-nav carousel-next"
53
+ aria-label="Next Slide"
54
+ >
55
+ <svg
56
+ class="carousel-icon"
57
+ viewBox="0 0 24 24"
58
+ fill="none"
59
+ stroke="currentColor"
60
+ stroke-width="2"
61
+ >
62
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
63
+ </svg>
64
+ </button>
65
+
66
+ <!-- Indicators -->
67
+ <div v-if="normalizedImages.length > 1" class="carousel-indicators">
68
+ <button
69
+ v-for="(_, idx) in normalizedImages"
70
+ :key="idx"
71
+ @click="goTo(idx)"
72
+ class="carousel-dot"
73
+ :class="{ active: activeIndex === idx }"
74
+ :aria-label="`Go to slide ${idx + 1}`"
75
+ ></button>
76
+ </div>
77
+ </div>
78
+ </template>
79
+
80
+ <script setup>
81
+ import { computed, ref } from "vue";
82
+
83
+ const props = defineProps({
84
+ images: {
85
+ type: Array,
86
+ required: true,
87
+ },
88
+ });
89
+
90
+ const container = ref(null);
91
+ const activeIndex = ref(0);
92
+
93
+ const normalizedImages = computed(() => {
94
+ if (!props.images) return [];
95
+ return props.images.map((img) => {
96
+ if (typeof img === "string") {
97
+ return { src: img, alt: "", caption: "" };
98
+ }
99
+ return {
100
+ src: img.src || "",
101
+ alt: img.alt || "",
102
+ caption: img.caption || "",
103
+ };
104
+ });
105
+ });
106
+
107
+ const handleScroll = () => {
108
+ if (!container.value) return;
109
+ const { scrollLeft, clientWidth } = container.value;
110
+ if (clientWidth === 0) return;
111
+ activeIndex.value = Math.round(scrollLeft / clientWidth);
112
+ };
113
+
114
+ const goTo = (index) => {
115
+ if (!container.value) return;
116
+ const width = container.value.clientWidth;
117
+ container.value.scrollTo({
118
+ left: width * index,
119
+ behavior: "smooth",
120
+ });
121
+ };
122
+
123
+ const prev = () => {
124
+ const index =
125
+ (activeIndex.value - 1 + normalizedImages.value.length) %
126
+ normalizedImages.value.length;
127
+ goTo(index);
128
+ };
129
+
130
+ const next = () => {
131
+ const index = (activeIndex.value + 1) % normalizedImages.value.length;
132
+ goTo(index);
133
+ };
134
+ </script>
135
+
136
+ <style scoped>
137
+ .image-carousel-container {
138
+ position: relative;
139
+ width: 100%;
140
+ margin: 1.5rem 0;
141
+ overflow: hidden;
142
+ border-radius: 8px;
143
+ border: 1px solid #e2e8f0;
144
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
145
+ background-color: #f8fafc;
146
+ }
147
+
148
+ .carousel-slider {
149
+ display: flex;
150
+ overflow-x: auto;
151
+ scroll-snap-type: x mandatory;
152
+ scroll-behavior: smooth;
153
+ }
154
+
155
+ /* Hide scrollbars */
156
+ .scrollbar-none::-webkit-scrollbar {
157
+ display: none;
158
+ }
159
+ .scrollbar-none {
160
+ -ms-overflow-style: none;
161
+ scrollbar-width: none;
162
+ }
163
+
164
+ .carousel-slide {
165
+ width: 100%;
166
+ flex-shrink: 0;
167
+ scroll-snap-align: start;
168
+ scroll-snap-stop: always;
169
+ position: relative;
170
+ display: flex;
171
+ flex-direction: column;
172
+ align-items: center;
173
+ justify-content: center;
174
+ }
175
+
176
+ .carousel-img {
177
+ width: 100%;
178
+ max-height: 500px;
179
+ object-fit: cover;
180
+ display: block;
181
+ }
182
+
183
+ .carousel-caption {
184
+ position: absolute;
185
+ bottom: 0;
186
+ left: 0;
187
+ right: 0;
188
+ background-color: rgba(0, 0, 0, 0.6);
189
+ color: #ffffff;
190
+ font-size: 0.875rem;
191
+ padding: 0.75rem 1.5rem;
192
+ text-align: center;
193
+ backdrop-filter: blur(4px);
194
+ }
195
+
196
+ .carousel-nav {
197
+ position: absolute;
198
+ top: 50%;
199
+ transform: translateY(-50%);
200
+ width: 40px;
201
+ height: 40px;
202
+ border-radius: 50%;
203
+ background-color: rgba(0, 0, 0, 0.4);
204
+ border: none;
205
+ color: #ffffff;
206
+ display: flex;
207
+ align-items: center;
208
+ justify-content: center;
209
+ cursor: pointer !important;
210
+ opacity: 0;
211
+ transition:
212
+ opacity 0.2s ease-in-out,
213
+ background-color 0.2s;
214
+ z-index: 10;
215
+ }
216
+
217
+ .carousel-nav:hover {
218
+ background-color: rgba(0, 0, 0, 0.6);
219
+ }
220
+
221
+ .image-carousel-container:hover .carousel-nav {
222
+ opacity: 1;
223
+ }
224
+
225
+ .carousel-prev {
226
+ left: 1rem;
227
+ }
228
+
229
+ .carousel-next {
230
+ right: 1rem;
231
+ }
232
+
233
+ .carousel-icon {
234
+ width: 24px;
235
+ height: 24px;
236
+ }
237
+
238
+ .carousel-indicators {
239
+ position: absolute;
240
+ bottom: 1rem;
241
+ left: 50%;
242
+ transform: translateX(-50%);
243
+ display: flex;
244
+ gap: 0.5rem;
245
+ z-index: 10;
246
+ }
247
+
248
+ .carousel-dot {
249
+ width: 10px;
250
+ height: 10px;
251
+ border-radius: 50%;
252
+ border: none;
253
+ background-color: rgba(255, 255, 255, 0.5);
254
+ cursor: pointer !important;
255
+ transition: all 0.3s;
256
+ padding: 0;
257
+ }
258
+
259
+ .carousel-dot:hover {
260
+ background-color: rgba(255, 255, 255, 0.85);
261
+ }
262
+
263
+ .carousel-dot.active {
264
+ background-color: #ffffff;
265
+ width: 20px;
266
+ border-radius: 5px;
267
+ }
268
+ </style>
@@ -0,0 +1,46 @@
1
+ import ImageElement from "./ImageElement.vue";
2
+
3
+ describe("<ImageElement />", () => {
4
+ it("renders standard image with caption and custom width", () => {
5
+ const src = "/images/someimage.png";
6
+ const caption = "Nice view of the <em>Earth</em>";
7
+ const width = "300px";
8
+
9
+ cy.mount(ImageElement, {
10
+ props: {
11
+ src,
12
+ caption,
13
+ width,
14
+ },
15
+ });
16
+
17
+ cy.get("img")
18
+ .should("have.attr", "src", src)
19
+ .should("have.css", "max-width", width);
20
+
21
+ cy.get("figcaption").should(
22
+ "contain.html",
23
+ "Nice view of the <em>Earth</em>",
24
+ );
25
+ });
26
+
27
+ it("renders compare slider when compare prop is provided", () => {
28
+ const src = "/images/2024.jpg";
29
+ const compare = "/images/2023.jpg";
30
+
31
+ cy.mount(ImageElement, {
32
+ props: {
33
+ src,
34
+ compare,
35
+ },
36
+ });
37
+
38
+ // Check that two images are rendered
39
+ cy.get("img").should("have.length", 2);
40
+ cy.get("img").eq(0).should("have.attr", "src", compare);
41
+ cy.get("img").eq(1).should("have.attr", "src", src);
42
+
43
+ // Check slider element exists
44
+ cy.get('input[type="range"]').should("exist").and("have.value", "50");
45
+ });
46
+ });