@iqual/nuxt-icms 1.2.1 → 1.2.3
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/components/DesignSystem/Molecules/MediaLightbox.vue +273 -0
- package/components/DesignSystem/Molecules/MediaSlider.vue +19 -25
- package/components/DesignSystem/README.md +489 -0
- package/components/Media/RemoteVideo/index.vue +3 -3
- package/components/Node/Icms/Page/index.vue +5 -3
- package/components/Paragraph/Card/Layout/index.vue +2 -2
- package/components/Paragraph/Contact/index.vue +1 -2
- package/components/Paragraph/Downloads/index.vue +1 -2
- package/components/Paragraph/FAQ/Layout/index.vue +1 -2
- package/components/Paragraph/FlipTeasers/index.vue +2 -2
- package/components/Paragraph/Gallery/index.vue +16 -14
- package/components/Paragraph/IconText/Layout/index.vue +1 -2
- package/components/Paragraph/Table/index.vue +2 -2
- package/components/Paragraph/Team/Layout/index.vue +2 -2
- package/components/Paragraph/Teasers/index.vue +3 -3
- package/components/Paragraph/Timeline/Layout/index.vue +6 -3
- package/components/Paragraph/iFrame/index.vue +16 -2
- package/components/Section/Footer/index.vue +15 -14
- package/components/Section/Header/GlobalMenu.vue +34 -34
- package/components/Section/Header/MetaMenu.vue +17 -19
- package/components/Section/Header/index.vue +21 -22
- package/composables/useGlobalConfig.ts +57 -0
- package/composables/useGraphqlCacheability.ts +22 -0
- package/composables/useInitData.ts +20 -112
- package/composables/useMediaLightbox.ts +114 -0
- package/composables/useMenu.ts +66 -0
- package/modules/dynamicEntityComponentResolver.ts +0 -20
- package/nuxt.config.ts +0 -5
- package/package.json +1 -2
- package/plugins/graphqlMiddleware.ts +27 -68
- package/plugins/initData.ts +105 -0
- package/{composables → queries}/query.initData.graphql +2 -8
- package/queries/query.loadTranslations.graphql +5 -0
- package/types/initData.ts +1 -0
- package/components/Section/Header/SearchLink.vue +0 -26
- package/composables/useLightbox.ts +0 -81
- package/composables/useMainMenuLinks.ts +0 -69
- package/composables/useMetaMenuLinks.ts +0 -44
- package/types/fslightbox-vue.d.ts +0 -1
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Teleport to="body">
|
|
3
|
+
<Transition
|
|
4
|
+
enter-active-class="transition-opacity duration-300"
|
|
5
|
+
enter-from-class="opacity-0"
|
|
6
|
+
enter-to-class="opacity-100"
|
|
7
|
+
leave-active-class="transition-opacity duration-300"
|
|
8
|
+
leave-from-class="opacity-100"
|
|
9
|
+
leave-to-class="opacity-0"
|
|
10
|
+
>
|
|
11
|
+
<div
|
|
12
|
+
v-if="isOpen"
|
|
13
|
+
ref="lightboxContainer"
|
|
14
|
+
class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm"
|
|
15
|
+
@click.self="$emit('close')"
|
|
16
|
+
>
|
|
17
|
+
<!-- Close button -->
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
class="absolute right-4 top-4 z-10 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-black"
|
|
21
|
+
aria-label="Close lightbox"
|
|
22
|
+
@click="$emit('close')"
|
|
23
|
+
>
|
|
24
|
+
<X :size="24" />
|
|
25
|
+
</button>
|
|
26
|
+
|
|
27
|
+
<!-- Loading spinner -->
|
|
28
|
+
<div
|
|
29
|
+
v-if="isLoading"
|
|
30
|
+
class="absolute inset-0 flex items-center justify-center"
|
|
31
|
+
aria-live="polite"
|
|
32
|
+
aria-label="Loading media"
|
|
33
|
+
>
|
|
34
|
+
<div class="h-8 w-8 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<!-- Carousel-based media container -->
|
|
38
|
+
<div class="relative h-full w-full max-h-[90vh] max-w-[90vw] flex items-center justify-center">
|
|
39
|
+
<Carousel
|
|
40
|
+
v-if="mediaObjects.length > 0"
|
|
41
|
+
:opts="carouselOptions"
|
|
42
|
+
orientation="horizontal"
|
|
43
|
+
class="w-full h-full flex flex-col"
|
|
44
|
+
@init-api="onCarouselInit"
|
|
45
|
+
>
|
|
46
|
+
<template #default="{ scrollPrev, scrollNext, canScrollPrev, canScrollNext }">
|
|
47
|
+
<!-- Navigation buttons using carousel methods -->
|
|
48
|
+
<button
|
|
49
|
+
v-if="mediaObjects.length > 1"
|
|
50
|
+
type="button"
|
|
51
|
+
class="absolute left-4 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-black disabled:opacity-50 disabled:cursor-not-allowed"
|
|
52
|
+
aria-label="Previous media"
|
|
53
|
+
:disabled="!canScrollPrev"
|
|
54
|
+
@click="scrollPrev"
|
|
55
|
+
>
|
|
56
|
+
<ChevronLeft :size="24" />
|
|
57
|
+
</button>
|
|
58
|
+
|
|
59
|
+
<button
|
|
60
|
+
v-if="mediaObjects.length > 1"
|
|
61
|
+
type="button"
|
|
62
|
+
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-black disabled:opacity-50 disabled:cursor-not-allowed"
|
|
63
|
+
aria-label="Next media"
|
|
64
|
+
:disabled="!canScrollNext"
|
|
65
|
+
@click="scrollNext"
|
|
66
|
+
>
|
|
67
|
+
<ChevronRight :size="24" />
|
|
68
|
+
</button>
|
|
69
|
+
|
|
70
|
+
<CarouselContent class="h-full flex-1">
|
|
71
|
+
<CarouselItem
|
|
72
|
+
v-for="(media, index) in mediaObjects"
|
|
73
|
+
:key="index"
|
|
74
|
+
class="flex h-full w-full items-center justify-center min-h-0"
|
|
75
|
+
>
|
|
76
|
+
<!-- Use Media component directly -->
|
|
77
|
+
<Media
|
|
78
|
+
:media="media"
|
|
79
|
+
:show-caption="false"
|
|
80
|
+
:video-autoplay="index === currentIndex && media.type === 'MediaVideo'"
|
|
81
|
+
:video-muted="true"
|
|
82
|
+
:video-controls="true"
|
|
83
|
+
:media-class="getMediaWrapperClass(media)"
|
|
84
|
+
:element-class="getMediaElementClass(media)"
|
|
85
|
+
class="max-h-full max-w-full"
|
|
86
|
+
@load="handleMediaLoad"
|
|
87
|
+
@error="handleMediaError"
|
|
88
|
+
/>
|
|
89
|
+
|
|
90
|
+
<!-- Fallback/error state -->
|
|
91
|
+
<div
|
|
92
|
+
v-if="!['MediaImage', 'MediaVideo', 'MediaRemoteVideo', 'MediaIcon'].includes(media.type)"
|
|
93
|
+
class="flex flex-col items-center text-white"
|
|
94
|
+
>
|
|
95
|
+
<AlertCircle :size="48" class="mb-4 text-red-400" />
|
|
96
|
+
<p class="text-lg">Unable to display this media</p>
|
|
97
|
+
<p class="text-sm text-gray-400">Unsupported format: {{ media.type }}</p>
|
|
98
|
+
</div>
|
|
99
|
+
</CarouselItem>
|
|
100
|
+
</CarouselContent>
|
|
101
|
+
</template>
|
|
102
|
+
</Carousel>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<!-- Caption display -->
|
|
106
|
+
<div
|
|
107
|
+
v-if="currentCaption"
|
|
108
|
+
class="absolute bottom-0 left-4 h-[5vh] right-4 z-10 text-center leading-[5vh]"
|
|
109
|
+
>
|
|
110
|
+
<p class="text-white">
|
|
111
|
+
{{ currentCaption }}
|
|
112
|
+
</p>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<!-- Counter -->
|
|
116
|
+
<div
|
|
117
|
+
v-if="mediaObjects.length > 1"
|
|
118
|
+
class="absolute top-2 left-2 z-10 rounded px-3 py-1 text-sm text-white"
|
|
119
|
+
aria-live="polite"
|
|
120
|
+
>
|
|
121
|
+
{{ currentIndex + 1 }} / {{ mediaObjects.length }}
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
</Transition>
|
|
125
|
+
</Teleport>
|
|
126
|
+
</template>
|
|
127
|
+
|
|
128
|
+
<script lang="ts" setup>
|
|
129
|
+
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
|
|
130
|
+
import { ChevronLeft, ChevronRight, X, AlertCircle } from 'lucide-vue-next'
|
|
131
|
+
import { Carousel, CarouselContent, CarouselItem } from '@iqual/nuxt-icms/components/ui/carousel'
|
|
132
|
+
import type { CarouselApi } from '@iqual/nuxt-icms/components/ui/carousel'
|
|
133
|
+
import { Media } from '#components'
|
|
134
|
+
import type { MediaFragment } from '#graphql-operations'
|
|
135
|
+
|
|
136
|
+
interface Props {
|
|
137
|
+
mediaObjects: MediaFragment[]
|
|
138
|
+
isOpen: boolean
|
|
139
|
+
currentIndex: number
|
|
140
|
+
captions?: string[]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface Emits {
|
|
144
|
+
(e: 'close'): void
|
|
145
|
+
(e: 'update:currentIndex', index: number): void
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
149
|
+
captions: () => []
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const emit = defineEmits<Emits>()
|
|
153
|
+
|
|
154
|
+
// Refs
|
|
155
|
+
const lightboxContainer = ref<HTMLElement>()
|
|
156
|
+
const isLoading = ref(false)
|
|
157
|
+
const carouselApi = ref<CarouselApi>()
|
|
158
|
+
|
|
159
|
+
// Computed properties
|
|
160
|
+
const currentCaption = computed(() => props.captions[props.currentIndex] || '')
|
|
161
|
+
|
|
162
|
+
// Carousel configuration
|
|
163
|
+
const carouselOptions = computed(() => ({
|
|
164
|
+
startIndex: props.currentIndex,
|
|
165
|
+
loop: true,
|
|
166
|
+
skipSnaps: false,
|
|
167
|
+
dragFree: false,
|
|
168
|
+
containScroll: 'trimSnaps' as const
|
|
169
|
+
}))
|
|
170
|
+
|
|
171
|
+
// Helper to get wrapper classes for media containers
|
|
172
|
+
const getMediaWrapperClass = (media: MediaFragment) => {
|
|
173
|
+
switch (media.type) {
|
|
174
|
+
case 'MediaRemoteVideo':
|
|
175
|
+
return 'w-full h-full flex items-center justify-center'
|
|
176
|
+
default:
|
|
177
|
+
return 'w-full h-full flex items-center justify-center'
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Helper to get element classes for media
|
|
182
|
+
const getMediaElementClass = (media: MediaFragment) => {
|
|
183
|
+
switch (media.type) {
|
|
184
|
+
case 'MediaImage':
|
|
185
|
+
return 'max-h-full max-w-full object-contain'
|
|
186
|
+
case 'MediaVideo':
|
|
187
|
+
return 'max-h-full max-w-full'
|
|
188
|
+
case 'MediaRemoteVideo':
|
|
189
|
+
return 'w-full h-full max-w-[90vw] max-h-[90vh]'
|
|
190
|
+
default:
|
|
191
|
+
return ''
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Media loading handlers
|
|
196
|
+
const handleMediaLoad = () => {
|
|
197
|
+
isLoading.value = false
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const handleMediaError = () => {
|
|
201
|
+
isLoading.value = false
|
|
202
|
+
console.warn('Failed to load media item')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Carousel event handlers
|
|
206
|
+
const onCarouselInit = (api: CarouselApi) => {
|
|
207
|
+
carouselApi.value = api
|
|
208
|
+
|
|
209
|
+
if (api) {
|
|
210
|
+
api.on('select', () => {
|
|
211
|
+
const selectedIndex = api.selectedScrollSnap()
|
|
212
|
+
emit('update:currentIndex', selectedIndex)
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Update carousel when currentIndex changes externally
|
|
218
|
+
const updateCarousel = async () => {
|
|
219
|
+
if (carouselApi.value) {
|
|
220
|
+
await nextTick()
|
|
221
|
+
carouselApi.value.scrollTo(props.currentIndex, false)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Watch for external index changes
|
|
226
|
+
watch(() => props.currentIndex, updateCarousel)
|
|
227
|
+
|
|
228
|
+
// Keyboard navigation
|
|
229
|
+
const handleKeydown = (event: KeyboardEvent) => {
|
|
230
|
+
if (!props.isOpen) return
|
|
231
|
+
|
|
232
|
+
switch (event.key) {
|
|
233
|
+
case 'Escape':
|
|
234
|
+
emit('close')
|
|
235
|
+
break
|
|
236
|
+
case 'ArrowLeft':
|
|
237
|
+
event.preventDefault()
|
|
238
|
+
carouselApi.value?.scrollPrev()
|
|
239
|
+
break
|
|
240
|
+
case 'ArrowRight':
|
|
241
|
+
event.preventDefault()
|
|
242
|
+
carouselApi.value?.scrollNext()
|
|
243
|
+
break
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Focus management
|
|
248
|
+
const focusLightbox = () => {
|
|
249
|
+
nextTick(() => {
|
|
250
|
+
lightboxContainer.value?.focus()
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Lifecycle
|
|
255
|
+
onMounted(() => {
|
|
256
|
+
document.addEventListener('keydown', handleKeydown)
|
|
257
|
+
if (props.isOpen) {
|
|
258
|
+
focusLightbox()
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
onUnmounted(() => {
|
|
263
|
+
document.removeEventListener('keydown', handleKeydown)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// Watch for lightbox opening
|
|
267
|
+
watch(() => props.isOpen, (isOpen) => {
|
|
268
|
+
if (isOpen) {
|
|
269
|
+
focusLightbox()
|
|
270
|
+
updateCarousel()
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
</script>
|
|
@@ -34,56 +34,50 @@
|
|
|
34
34
|
</CarouselNext>
|
|
35
35
|
</Carousel>
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
:
|
|
40
|
-
:
|
|
41
|
-
:
|
|
37
|
+
<!-- MediaLightbox with Media component integration -->
|
|
38
|
+
<DesignSystemMoleculesMediaLightbox
|
|
39
|
+
:media-objects="media?.filter((item): item is MediaFragment => Boolean(item)) || []"
|
|
40
|
+
:is-open="isOpen"
|
|
41
|
+
:current-index="currentIndex"
|
|
42
42
|
:captions="captions"
|
|
43
|
+
@close="closeLightbox"
|
|
44
|
+
@update:current-index="setCurrentIndex"
|
|
43
45
|
/>
|
|
44
46
|
</div>
|
|
45
47
|
</template>
|
|
46
48
|
|
|
47
49
|
<script lang="ts" setup>
|
|
48
50
|
import cn from '@iqual/nuxt-icms/composables/useCn'
|
|
51
|
+
import { useMediaLightbox } from '@iqual/nuxt-icms/composables/useMediaLightbox'
|
|
49
52
|
import { tv } from 'tailwind-variants'
|
|
50
53
|
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
|
51
54
|
import type { MediaFragment } from '#graphql-operations'
|
|
52
|
-
import FsLightbox from 'fslightbox-vue'
|
|
53
55
|
|
|
54
56
|
const props = defineProps<{
|
|
55
57
|
media?: (MediaFragment | null)[] | undefined
|
|
56
58
|
sliderClass?: string
|
|
57
59
|
mediaClass?: string
|
|
58
|
-
colorScheme?:
|
|
60
|
+
colorScheme?: 'white'
|
|
59
61
|
showCaption?: boolean
|
|
60
62
|
figcaptionClass?: string
|
|
61
63
|
elementClass?: string
|
|
62
64
|
}>()
|
|
63
65
|
|
|
64
66
|
const {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
isOpen,
|
|
68
|
+
currentIndex,
|
|
67
69
|
openLightbox,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
closeLightbox,
|
|
71
|
+
setCurrentIndex,
|
|
70
72
|
captions,
|
|
71
|
-
} =
|
|
73
|
+
} = useMediaLightbox(props.media)
|
|
72
74
|
|
|
73
75
|
const carouselButton = tv({
|
|
74
|
-
base: '
|
|
76
|
+
base: 'bg-white text-black ring-black',
|
|
75
77
|
variants: {
|
|
76
78
|
colorScheme: {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
'bg-gray-50 text-black hover:bg-black hover:text-white dark:bg-gray-50 dark:text-black hover:dark:bg-black hover:dark:text-white',
|
|
81
|
-
accent:
|
|
82
|
-
'bg-primary-600 text-white hover:bg-primary-800 hover:text-white dark:bg-primary-600 dark:text-white hover:dark:bg-primary-800 hover:dark:text-white',
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
defaultVariants: {
|
|
86
|
-
colorScheme: 'none',
|
|
87
|
-
},
|
|
79
|
+
white: 'bg-black text-white ring-white'
|
|
80
|
+
}
|
|
81
|
+
}
|
|
88
82
|
})
|
|
89
|
-
</script>
|
|
83
|
+
</script>
|