@eox/pages-theme-eox 1.8.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  # [1.8.0](https://gitlab.eox.at/eox/hub/eoxhub-portal/compare/v1.7.0...v1.8.0) (2026-06-08)
4
10
 
5
11
  ### 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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eox/pages-theme-eox",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "description": "Vitepress Theme with EOX branding",
6
6
  "main": "src/index.js",
@@ -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
+ });
@@ -0,0 +1,269 @@
1
+ <template>
2
+ <div class="image-element-container">
3
+ <figure class="image-element-figure">
4
+ <!-- Image Compare Mode -->
5
+ <div
6
+ v-if="compare"
7
+ class="compare-wrapper"
8
+ :style="{ maxWidth: width || '100%' }"
9
+ >
10
+ <!-- Background Image (Original/Right) -->
11
+ <img
12
+ :src="compare"
13
+ :alt="alt || 'Image comparison background'"
14
+ class="compare-img compare-bg"
15
+ />
16
+
17
+ <!-- Foreground Image (Overlay/Left) -->
18
+ <div
19
+ class="compare-overlay"
20
+ :style="{
21
+ clipPath: `polygon(0 0, ${sliderValue}% 0, ${sliderValue}% 100%, 0 100%)`,
22
+ }"
23
+ >
24
+ <img
25
+ :src="src"
26
+ :alt="alt || 'Image comparison foreground'"
27
+ class="compare-img compare-fg"
28
+ />
29
+ </div>
30
+
31
+ <!-- Slider Line and Handle -->
32
+ <div class="compare-handle-line" :style="{ left: `${sliderValue}%` }">
33
+ <div class="compare-handle-circle">
34
+ <svg
35
+ class="compare-handle-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="M8 9l-3 3 3 3m8-6l3 3-3 3"
45
+ />
46
+ </svg>
47
+ </div>
48
+ </div>
49
+
50
+ <!-- Range Input Overlay (covers full container to handle drag smoothly) -->
51
+ <input
52
+ type="range"
53
+ min="0"
54
+ max="100"
55
+ v-model="sliderValue"
56
+ class="compare-range-input"
57
+ aria-label="Image comparison slider"
58
+ />
59
+ </div>
60
+
61
+ <!-- Standard Image Mode -->
62
+ <img
63
+ v-else
64
+ :src="src"
65
+ :alt="alt || 'Post image'"
66
+ :style="{ maxWidth: width || '100%' }"
67
+ class="standard-img"
68
+ />
69
+
70
+ <!-- Caption -->
71
+ <figcaption
72
+ v-if="caption"
73
+ class="image-caption"
74
+ v-html="caption"
75
+ ></figcaption>
76
+ </figure>
77
+ </div>
78
+ </template>
79
+
80
+ <script setup>
81
+ import { ref } from "vue";
82
+
83
+ const props = defineProps({
84
+ src: {
85
+ type: String,
86
+ required: true,
87
+ },
88
+ compare: {
89
+ type: String,
90
+ default: "",
91
+ },
92
+ alt: {
93
+ type: String,
94
+ default: "",
95
+ },
96
+ width: {
97
+ type: String,
98
+ default: "",
99
+ },
100
+ caption: {
101
+ type: String,
102
+ default: "",
103
+ },
104
+ });
105
+
106
+ const sliderValue = ref(50);
107
+ </script>
108
+
109
+ <style scoped>
110
+ .image-element-container {
111
+ display: flex;
112
+ flex-direction: column;
113
+ align-items: center;
114
+ justify-content: center;
115
+ margin: 1.5rem 0;
116
+ width: 100%;
117
+ }
118
+
119
+ .image-element-figure {
120
+ width: 100%;
121
+ display: flex;
122
+ flex-direction: column;
123
+ align-items: center;
124
+ justify-content: center;
125
+ text-align: center;
126
+ margin: 0.5rem auto;
127
+ }
128
+
129
+ .compare-wrapper {
130
+ position: relative;
131
+ overflow: hidden;
132
+ user-select: none;
133
+ width: 100%;
134
+ border-radius: 8px;
135
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
136
+ border: 1px solid #e2e8f0;
137
+ }
138
+
139
+ .compare-img {
140
+ width: 100%;
141
+ height: auto;
142
+ display: block;
143
+ object-fit: cover;
144
+ pointer-events: none;
145
+ }
146
+
147
+ .compare-bg {
148
+ /* base background */
149
+ }
150
+
151
+ .compare-overlay {
152
+ position: absolute;
153
+ top: 0;
154
+ left: 0;
155
+ right: 0;
156
+ bottom: 0;
157
+ overflow: hidden;
158
+ pointer-events: none;
159
+ }
160
+
161
+ .compare-fg {
162
+ position: absolute;
163
+ top: 0;
164
+ left: 0;
165
+ width: 100%;
166
+ height: 100%;
167
+ max-width: none;
168
+ }
169
+
170
+ .compare-handle-line {
171
+ position: absolute;
172
+ top: 0;
173
+ bottom: 0;
174
+ width: 2px;
175
+ background-color: #ffffff;
176
+ cursor: ew-resize;
177
+ pointer-events: none;
178
+ z-index: 10;
179
+ box-shadow: 0 0 3px rgba(0, 0, 0, 0.3);
180
+ }
181
+
182
+ .compare-handle-circle {
183
+ position: absolute;
184
+ top: 50%;
185
+ left: 50%;
186
+ transform: translate(-50%, -50%);
187
+ width: 32px;
188
+ height: 32px;
189
+ border-radius: 50%;
190
+ background-color: #ffffff;
191
+ border: 1px solid #cbd5e1;
192
+ display: flex;
193
+ align-items: center;
194
+ justify-content: center;
195
+ box-shadow:
196
+ 0 4px 6px -1px rgba(0, 0, 0, 0.1),
197
+ 0 2px 4px -1px rgba(0, 0, 0, 0.06);
198
+ }
199
+
200
+ .compare-handle-icon {
201
+ width: 20px;
202
+ height: 20px;
203
+ color: #64748b;
204
+ }
205
+
206
+ /* Range Input Overlay to handle drag */
207
+ .compare-range-input {
208
+ position: absolute;
209
+ top: 0;
210
+ left: 0;
211
+ width: 100%;
212
+ height: 100%;
213
+ opacity: 0;
214
+ cursor: ew-resize;
215
+ z-index: 20;
216
+ margin: 0;
217
+ -webkit-appearance: none;
218
+ appearance: none;
219
+ background: transparent;
220
+ border: none;
221
+ }
222
+
223
+ .compare-range-input::-webkit-slider-runnable-track {
224
+ background: transparent;
225
+ border: none;
226
+ height: 100%;
227
+ width: 100%;
228
+ }
229
+
230
+ .compare-range-input::-webkit-slider-thumb {
231
+ -webkit-appearance: none;
232
+ appearance: none;
233
+ height: 100%;
234
+ width: 40px;
235
+ background: transparent;
236
+ cursor: ew-resize;
237
+ margin-top: 0;
238
+ }
239
+
240
+ .compare-range-input::-moz-range-track {
241
+ background: transparent;
242
+ border: none;
243
+ height: 100%;
244
+ }
245
+
246
+ .compare-range-input::-moz-range-thumb {
247
+ height: 100%;
248
+ width: 40px;
249
+ background: transparent;
250
+ cursor: ew-resize;
251
+ border: none;
252
+ }
253
+
254
+ .standard-img {
255
+ border-radius: 8px;
256
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
257
+ display: block;
258
+ height: auto;
259
+ }
260
+
261
+ .image-caption {
262
+ font-size: 0.85rem;
263
+ margin-top: 0.75rem;
264
+ text-align: center;
265
+ color: #64748b;
266
+ max-width: 40rem;
267
+ line-height: 1.5;
268
+ }
269
+ </style>