@xenterprises/nuxt-x-marketing 0.0.24 → 1.0.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/.claude/settings.local.json +22 -0
- package/.playground/app.config.ts +44 -45
- package/.playground/app.vue +263 -0
- package/.playground/components/AppFooter.vue +103 -0
- package/.playground/components/AppHeader.vue +41 -0
- package/.playground/components/CodeExample.vue +70 -0
- package/.playground/components/ComponentPlayground.vue +117 -0
- package/.playground/components/DocsHeader.vue +19 -0
- package/.playground/layouts/default.vue +13 -3
- package/.playground/layouts/docs-layout.vue +161 -0
- package/.playground/layouts/docs.vue +11 -0
- package/.playground/package-lock.json +6 -0
- package/.playground/pages/blog/author.vue +145 -0
- package/.playground/pages/blog/card.vue +149 -0
- package/.playground/pages/blog/cta.vue +131 -0
- package/.playground/pages/blog/detail.vue +379 -0
- package/.playground/pages/blog/list.vue +261 -0
- package/.playground/pages/blog/navigation.vue +228 -0
- package/.playground/pages/blog/sidebar.vue +279 -0
- package/.playground/pages/config.vue +289 -0
- package/.playground/pages/content/comparison.vue +253 -0
- package/.playground/pages/content/cta.vue +245 -0
- package/.playground/pages/content/features.vue +217 -0
- package/.playground/pages/content/newsletter-section.vue +260 -0
- package/.playground/pages/content/newsletter.vue +200 -0
- package/.playground/pages/content/pricing.vue +335 -0
- package/.playground/pages/content/testimonials.vue +188 -0
- package/.playground/pages/hero.vue +257 -0
- package/.playground/pages/index.vue +379 -2
- package/.playground/pages/layout/footer.vue +210 -0
- package/.playground/pages/layout/legal-footer.vue +121 -0
- package/.playground/pages/layout/navbar.vue +285 -0
- package/.playground/pages/layout/section.vue +266 -0
- package/.playground/pages/layout/x-legal.vue +199 -0
- package/.playground/pages/modals/feature-modal.vue +320 -0
- package/.playground/pages/modals/image-lightbox.vue +277 -0
- package/.playground/pages/modals/video-modal.vue +222 -0
- package/.playground/pages/notifications/announcement-bar.vue +164 -0
- package/.playground/pages/notifications/cookie-banner.vue +210 -0
- package/.playground/pages/notifications/cookie-toast.vue +143 -0
- package/.playground/pages/notifications/gdpr.vue +276 -0
- package/.playground/pages/notifications/social-proof-toast.vue +255 -0
- package/.playground/pages/ui/badge.vue +161 -0
- package/.playground/pages/ui/bg-pattern.vue +177 -0
- package/.playground/pages/ui/glass-card.vue +179 -0
- package/.playground/pages/ui/glow-divider.vue +179 -0
- package/.playground/pages/ui/promo-card.vue +259 -0
- package/.playground/pages/ui/section-stitch.vue +411 -0
- package/.playground/pages/utilities/back-to-top.vue +153 -0
- package/.playground/pages/utilities/popup-chat.vue +224 -0
- package/README.md +799 -164
- package/app/app.config.ts +88 -2
- package/app/app.vue +106 -11
- package/app/assets/css/x-marketing.css +668 -0
- package/app/components/X/Footer/XLegal/index.vue +63 -26
- package/app/components/X/Footer/index.vue +89 -89
- package/app/components/X/Header/App.vue +63 -0
- package/app/components/X/Mark/Blog/Author.vue +170 -0
- package/app/components/X/Mark/Blog/CTANewsletter.vue +197 -0
- package/app/components/X/Mark/Blog/Card.vue +117 -0
- package/app/components/X/Mark/Blog/Detail.vue +240 -0
- package/app/components/X/Mark/Blog/List.vue +156 -0
- package/app/components/X/Mark/Blog/Navigation.vue +100 -0
- package/app/components/X/Mark/Blog/Sidebar.vue +160 -0
- package/app/components/X/Mark/Button/BackToTop.vue +99 -0
- package/app/components/X/Mark/CTA/index.vue +294 -0
- package/app/components/X/Mark/Card/Glass.vue +64 -0
- package/app/components/X/Mark/Card/Promo.vue +164 -0
- package/app/components/X/Mark/Divider/Glow.vue +113 -0
- package/app/components/X/Mark/Features/index.vue +194 -0
- package/app/components/X/Mark/Hero/index.vue +217 -0
- package/app/components/X/Mark/Layout/AnnouncementBar.vue +139 -0
- package/app/components/X/Mark/Layout/Footer.vue +160 -0
- package/app/components/X/Mark/Layout/FooterLegal.vue +42 -0
- package/app/components/X/Mark/Layout/Navbar.vue +267 -0
- package/app/components/X/Mark/Modal/Chat.vue +151 -0
- package/app/components/X/Mark/Modal/Feature.vue +197 -0
- package/app/components/X/Mark/Modal/Image.vue +226 -0
- package/app/components/X/Mark/Modal/Video.vue +203 -0
- package/app/components/X/Mark/Modal/index.vue +133 -0
- package/app/components/X/Mark/Newsletter/Form.vue +174 -0
- package/app/components/X/Mark/Pattern/Bg.vue +86 -0
- package/app/components/X/Mark/Pricing/Comparison.vue +126 -0
- package/app/components/X/Mark/Pricing/Plans.vue +181 -0
- package/app/components/X/Mark/Privacy/CookieBanner.vue +112 -0
- package/app/components/X/Mark/Privacy/CookieToast.vue +43 -0
- package/app/components/X/Mark/Privacy/GDPR.vue +245 -0
- package/app/components/X/Mark/Section/Newsletter.vue +183 -0
- package/app/components/X/Mark/Section/Stitch.vue +156 -0
- package/app/components/X/Mark/Section/index.vue +151 -0
- package/app/components/X/Mark/SocialProof/Badge.vue +102 -0
- package/app/components/X/Mark/SocialProof/Testimonials.vue +326 -0
- package/app/components/X/Mark/SocialProof/Toast.vue +136 -0
- package/app/components/X/Mark/_archive/Pattern/Background.vue +83 -0
- package/app/components/X/Mark/_archive/Pattern/CheckBg.vue +35 -0
- package/app/components/X/Mark/_archive/Pattern/CircuitBg.vue +41 -0
- package/app/components/X/Mark/_archive/Pattern/DotsBg.vue +35 -0
- package/app/components/X/Mark/_archive/Pattern/GlowDivider.vue +86 -0
- package/app/components/X/Mark/_archive/Pattern/HexBg.vue +35 -0
- package/app/components/X/Mark/_archive/Pattern/PlaidBg.vue +47 -0
- package/app/components/X/Mark/_archive/Pattern/SectionStitch.vue +168 -0
- package/app/components/X/Mark/_archive/Pattern/WaveBg.vue +41 -0
- package/app/components/X/_archive/Newsletter/Form.vue +123 -0
- package/app/components/X/_archive/Newsletter/Section.vue +131 -0
- package/app/composables/useParallax.ts +124 -0
- package/app/composables/useScrollReveal.ts +92 -0
- package/app/pages/index.vue +210 -0
- package/app/plugins/marketing.client.ts +13 -0
- package/app/types/marketing.d.ts +268 -0
- package/componentList.json +511 -0
- package/nuxt.config.ts +5 -8
- package/package.json +21 -9
- package/.editorconfig +0 -12
- package/app/assets/css/main.css +0 -9
- package/app/components/X/Marketing/Sections/EmailSubscription/index.vue +0 -129
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Teleport to="body">
|
|
3
|
+
<Transition
|
|
4
|
+
enter-active-class="transition-opacity duration-300"
|
|
5
|
+
enter-from-class="opacity-0"
|
|
6
|
+
leave-active-class="transition-opacity duration-200"
|
|
7
|
+
leave-to-class="opacity-0"
|
|
8
|
+
>
|
|
9
|
+
<div
|
|
10
|
+
v-if="isOpen"
|
|
11
|
+
class="fixed inset-0 z-50 flex items-center justify-center"
|
|
12
|
+
@click="close"
|
|
13
|
+
@keydown.escape="close"
|
|
14
|
+
>
|
|
15
|
+
<!-- Backdrop -->
|
|
16
|
+
<div class="absolute inset-0 bg-black/90" />
|
|
17
|
+
|
|
18
|
+
<!-- Close Button -->
|
|
19
|
+
<button
|
|
20
|
+
class="absolute top-4 right-4 w-10 h-10 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors z-10"
|
|
21
|
+
@click.stop="close"
|
|
22
|
+
>
|
|
23
|
+
<UIcon name="i-lucide-x" class="w-6 h-6" />
|
|
24
|
+
<span class="sr-only">Close</span>
|
|
25
|
+
</button>
|
|
26
|
+
|
|
27
|
+
<!-- Navigation (for galleries) -->
|
|
28
|
+
<template v-if="images.length > 1">
|
|
29
|
+
<button
|
|
30
|
+
class="absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors z-10"
|
|
31
|
+
@click.stop="prev"
|
|
32
|
+
>
|
|
33
|
+
<UIcon name="i-lucide-chevron-left" class="w-6 h-6" />
|
|
34
|
+
<span class="sr-only">Previous</span>
|
|
35
|
+
</button>
|
|
36
|
+
<button
|
|
37
|
+
class="absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors z-10"
|
|
38
|
+
@click.stop="next"
|
|
39
|
+
>
|
|
40
|
+
<UIcon name="i-lucide-chevron-right" class="w-6 h-6" />
|
|
41
|
+
<span class="sr-only">Next</span>
|
|
42
|
+
</button>
|
|
43
|
+
</template>
|
|
44
|
+
|
|
45
|
+
<!-- Image Container -->
|
|
46
|
+
<div
|
|
47
|
+
class="relative max-w-[90vw] max-h-[90vh]"
|
|
48
|
+
@click.stop
|
|
49
|
+
>
|
|
50
|
+
<Transition
|
|
51
|
+
:name="slideDirection"
|
|
52
|
+
mode="out-in"
|
|
53
|
+
>
|
|
54
|
+
<figure :key="currentIndex" class="flex flex-col items-center">
|
|
55
|
+
<img
|
|
56
|
+
:src="currentImage.src"
|
|
57
|
+
:alt="currentImage.alt"
|
|
58
|
+
class="max-w-full max-h-[80vh] object-contain rounded-lg"
|
|
59
|
+
@load="loading = false"
|
|
60
|
+
/>
|
|
61
|
+
<figcaption
|
|
62
|
+
v-if="currentImage.caption"
|
|
63
|
+
class="mt-4 text-center text-white/80 text-sm max-w-lg"
|
|
64
|
+
>
|
|
65
|
+
{{ currentImage.caption }}
|
|
66
|
+
</figcaption>
|
|
67
|
+
</figure>
|
|
68
|
+
</Transition>
|
|
69
|
+
|
|
70
|
+
<!-- Loading indicator -->
|
|
71
|
+
<div
|
|
72
|
+
v-if="loading"
|
|
73
|
+
class="absolute inset-0 flex items-center justify-center"
|
|
74
|
+
>
|
|
75
|
+
<UIcon name="i-lucide-loader-2" class="w-8 h-8 text-white animate-spin" />
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<!-- Counter -->
|
|
80
|
+
<div
|
|
81
|
+
v-if="images.length > 1 && hasCounter"
|
|
82
|
+
class="absolute bottom-4 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full bg-white/10 text-white text-sm"
|
|
83
|
+
>
|
|
84
|
+
{{ currentIndex + 1 }} / {{ images.length }}
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<!-- Thumbnails -->
|
|
88
|
+
<div
|
|
89
|
+
v-if="images.length > 1 && hasThumbnails"
|
|
90
|
+
class="absolute bottom-16 left-1/2 -translate-x-1/2 flex gap-2"
|
|
91
|
+
>
|
|
92
|
+
<button
|
|
93
|
+
v-for="(image, index) in images"
|
|
94
|
+
:key="index"
|
|
95
|
+
class="w-12 h-12 rounded-lg overflow-hidden border-2 transition-all"
|
|
96
|
+
:class="index === currentIndex ? 'border-white' : 'border-transparent opacity-50 hover:opacity-75'"
|
|
97
|
+
@click.stop="currentIndex = index"
|
|
98
|
+
>
|
|
99
|
+
<img
|
|
100
|
+
:src="image.src"
|
|
101
|
+
:alt="image.alt"
|
|
102
|
+
class="w-full h-full object-cover"
|
|
103
|
+
/>
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</Transition>
|
|
108
|
+
</Teleport>
|
|
109
|
+
</template>
|
|
110
|
+
|
|
111
|
+
<script setup>
|
|
112
|
+
const props = defineProps({
|
|
113
|
+
/** v-model for lightbox visibility */
|
|
114
|
+
modelValue: {
|
|
115
|
+
type: Boolean,
|
|
116
|
+
default: false,
|
|
117
|
+
},
|
|
118
|
+
/** Array of images or single image */
|
|
119
|
+
images: {
|
|
120
|
+
type: Array,
|
|
121
|
+
default: () => [],
|
|
122
|
+
// Each image: { src, alt, caption? }
|
|
123
|
+
},
|
|
124
|
+
/** Starting index for galleries */
|
|
125
|
+
startIndex: {
|
|
126
|
+
type: Number,
|
|
127
|
+
default: 0,
|
|
128
|
+
},
|
|
129
|
+
/** Show image counter */
|
|
130
|
+
hasCounter: {
|
|
131
|
+
type: Boolean,
|
|
132
|
+
default: true,
|
|
133
|
+
},
|
|
134
|
+
/** Show thumbnail navigation */
|
|
135
|
+
hasThumbnails: {
|
|
136
|
+
type: Boolean,
|
|
137
|
+
default: false,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const emit = defineEmits(['update:modelValue', 'change']);
|
|
142
|
+
|
|
143
|
+
const currentIndex = ref(props.startIndex);
|
|
144
|
+
const loading = ref(false);
|
|
145
|
+
const slideDirection = ref('slide-left');
|
|
146
|
+
|
|
147
|
+
const isOpen = computed({
|
|
148
|
+
get: () => props.modelValue,
|
|
149
|
+
set: (value) => emit('update:modelValue', value),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const currentImage = computed(() => {
|
|
153
|
+
return props.images[currentIndex.value] || { src: '', alt: '' };
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const close = () => {
|
|
157
|
+
isOpen.value = false;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const next = () => {
|
|
161
|
+
loading.value = true;
|
|
162
|
+
slideDirection.value = 'slide-left';
|
|
163
|
+
currentIndex.value = (currentIndex.value + 1) % props.images.length;
|
|
164
|
+
emit('change', currentIndex.value);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const prev = () => {
|
|
168
|
+
loading.value = true;
|
|
169
|
+
slideDirection.value = 'slide-right';
|
|
170
|
+
currentIndex.value = (currentIndex.value - 1 + props.images.length) % props.images.length;
|
|
171
|
+
emit('change', currentIndex.value);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Keyboard navigation
|
|
175
|
+
const handleKeydown = (e) => {
|
|
176
|
+
if (!isOpen.value) return;
|
|
177
|
+
|
|
178
|
+
if (e.key === 'ArrowRight') next();
|
|
179
|
+
if (e.key === 'ArrowLeft') prev();
|
|
180
|
+
if (e.key === 'Escape') close();
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
onMounted(() => {
|
|
184
|
+
window.addEventListener('keydown', handleKeydown);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
onUnmounted(() => {
|
|
188
|
+
window.removeEventListener('keydown', handleKeydown);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Reset to startIndex when opening
|
|
192
|
+
watch(isOpen, (open) => {
|
|
193
|
+
if (open) {
|
|
194
|
+
currentIndex.value = props.startIndex;
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
</script>
|
|
198
|
+
|
|
199
|
+
<style scoped>
|
|
200
|
+
.slide-left-enter-active,
|
|
201
|
+
.slide-left-leave-active,
|
|
202
|
+
.slide-right-enter-active,
|
|
203
|
+
.slide-right-leave-active {
|
|
204
|
+
transition: transform 0.3s ease, opacity 0.3s ease;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.slide-left-enter-from {
|
|
208
|
+
transform: translateX(20px);
|
|
209
|
+
opacity: 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.slide-left-leave-to {
|
|
213
|
+
transform: translateX(-20px);
|
|
214
|
+
opacity: 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.slide-right-enter-from {
|
|
218
|
+
transform: translateX(-20px);
|
|
219
|
+
opacity: 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.slide-right-leave-to {
|
|
223
|
+
transform: translateX(20px);
|
|
224
|
+
opacity: 0;
|
|
225
|
+
}
|
|
226
|
+
</style>
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<XMarkModal
|
|
3
|
+
ref="modalRef"
|
|
4
|
+
:title="title"
|
|
5
|
+
:description="description"
|
|
6
|
+
:size="size"
|
|
7
|
+
dark
|
|
8
|
+
@open="emit('open')"
|
|
9
|
+
@close="handleClose"
|
|
10
|
+
>
|
|
11
|
+
<!-- Trigger Slot -->
|
|
12
|
+
<slot>
|
|
13
|
+
<!-- Thumbnail Trigger -->
|
|
14
|
+
<button
|
|
15
|
+
v-if="thumbnail"
|
|
16
|
+
class="relative group rounded-xl overflow-hidden cursor-pointer"
|
|
17
|
+
>
|
|
18
|
+
<img
|
|
19
|
+
:src="thumbnail"
|
|
20
|
+
:alt="title || 'Video thumbnail'"
|
|
21
|
+
class="w-full aspect-video object-cover transition-transform group-hover:scale-105"
|
|
22
|
+
/>
|
|
23
|
+
<div class="absolute inset-0 bg-black/40 flex items-center justify-center group-hover:bg-black/50 transition-colors">
|
|
24
|
+
<div class="w-16 h-16 rounded-full bg-white/90 flex items-center justify-center shadow-lg">
|
|
25
|
+
<UIcon name="i-lucide-play" class="w-8 h-8 text-neutral-900 ml-1" />
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
</button>
|
|
29
|
+
|
|
30
|
+
<!-- Button Trigger -->
|
|
31
|
+
<UButton
|
|
32
|
+
v-else
|
|
33
|
+
:label="buttonProps.label"
|
|
34
|
+
:color="buttonProps.color"
|
|
35
|
+
:variant="buttonProps.variant"
|
|
36
|
+
:size="buttonProps.size"
|
|
37
|
+
:leading-icon="buttonProps.icon"
|
|
38
|
+
/>
|
|
39
|
+
</slot>
|
|
40
|
+
|
|
41
|
+
<!-- Video Content -->
|
|
42
|
+
<template #content>
|
|
43
|
+
<div class="aspect-video bg-black overflow-hidden" :class="title || description ? 'rounded-t-lg' : 'rounded-lg'">
|
|
44
|
+
<!-- YouTube Embed -->
|
|
45
|
+
<iframe
|
|
46
|
+
v-if="modalRef?.isOpen && embedType === 'youtube'"
|
|
47
|
+
:src="embedUrl"
|
|
48
|
+
class="w-full h-full"
|
|
49
|
+
frameborder="0"
|
|
50
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
51
|
+
allowfullscreen
|
|
52
|
+
/>
|
|
53
|
+
|
|
54
|
+
<!-- Vimeo Embed -->
|
|
55
|
+
<iframe
|
|
56
|
+
v-else-if="modalRef?.isOpen && embedType === 'vimeo'"
|
|
57
|
+
:src="embedUrl"
|
|
58
|
+
class="w-full h-full"
|
|
59
|
+
frameborder="0"
|
|
60
|
+
allow="autoplay; fullscreen; picture-in-picture"
|
|
61
|
+
allowfullscreen
|
|
62
|
+
/>
|
|
63
|
+
|
|
64
|
+
<!-- Direct Video -->
|
|
65
|
+
<video
|
|
66
|
+
v-else-if="modalRef?.isOpen"
|
|
67
|
+
ref="videoRef"
|
|
68
|
+
:src="src"
|
|
69
|
+
:poster="poster"
|
|
70
|
+
class="w-full h-full object-contain"
|
|
71
|
+
controls
|
|
72
|
+
:autoplay="autoplay"
|
|
73
|
+
>
|
|
74
|
+
<track
|
|
75
|
+
v-if="captions"
|
|
76
|
+
kind="captions"
|
|
77
|
+
:src="captions"
|
|
78
|
+
:srclang="captionsLang"
|
|
79
|
+
:label="captionsLabel"
|
|
80
|
+
/>
|
|
81
|
+
</video>
|
|
82
|
+
</div>
|
|
83
|
+
</template>
|
|
84
|
+
</XMarkModal>
|
|
85
|
+
</template>
|
|
86
|
+
|
|
87
|
+
<script setup>
|
|
88
|
+
const props = defineProps({
|
|
89
|
+
/** Video source (direct URL, YouTube, or Vimeo) */
|
|
90
|
+
src: {
|
|
91
|
+
type: String,
|
|
92
|
+
required: true,
|
|
93
|
+
},
|
|
94
|
+
/** Thumbnail image URL (shows image with play button) */
|
|
95
|
+
thumbnail: {
|
|
96
|
+
type: String,
|
|
97
|
+
default: '',
|
|
98
|
+
},
|
|
99
|
+
/** Poster image for direct videos */
|
|
100
|
+
poster: {
|
|
101
|
+
type: String,
|
|
102
|
+
default: '',
|
|
103
|
+
},
|
|
104
|
+
/** Video title (shown in modal footer) */
|
|
105
|
+
title: {
|
|
106
|
+
type: String,
|
|
107
|
+
default: '',
|
|
108
|
+
},
|
|
109
|
+
/** Video description (shown in modal footer) */
|
|
110
|
+
description: {
|
|
111
|
+
type: String,
|
|
112
|
+
default: '',
|
|
113
|
+
},
|
|
114
|
+
/** Modal size */
|
|
115
|
+
size: {
|
|
116
|
+
type: String,
|
|
117
|
+
default: '4xl',
|
|
118
|
+
validator: (v) => ['lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', '7xl', 'full'].includes(v),
|
|
119
|
+
},
|
|
120
|
+
/** Autoplay video when modal opens */
|
|
121
|
+
autoplay: {
|
|
122
|
+
type: Boolean,
|
|
123
|
+
default: true,
|
|
124
|
+
},
|
|
125
|
+
/** Button configuration object */
|
|
126
|
+
button: {
|
|
127
|
+
type: Object,
|
|
128
|
+
default: () => ({}),
|
|
129
|
+
},
|
|
130
|
+
/** Captions/subtitles file URL */
|
|
131
|
+
captions: {
|
|
132
|
+
type: String,
|
|
133
|
+
default: '',
|
|
134
|
+
},
|
|
135
|
+
/** Captions language code */
|
|
136
|
+
captionsLang: {
|
|
137
|
+
type: String,
|
|
138
|
+
default: 'en',
|
|
139
|
+
},
|
|
140
|
+
/** Captions label */
|
|
141
|
+
captionsLabel: {
|
|
142
|
+
type: String,
|
|
143
|
+
default: 'English',
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const emit = defineEmits(['open', 'close']);
|
|
148
|
+
|
|
149
|
+
const modalRef = ref(null);
|
|
150
|
+
const videoRef = ref(null);
|
|
151
|
+
|
|
152
|
+
// Button props with defaults
|
|
153
|
+
const buttonProps = computed(() => ({
|
|
154
|
+
label: props.button.label || 'Watch Video',
|
|
155
|
+
color: props.button.color || 'primary',
|
|
156
|
+
variant: props.button.variant || 'solid',
|
|
157
|
+
size: props.button.size || 'md',
|
|
158
|
+
icon: props.button.icon || 'i-lucide-play',
|
|
159
|
+
}));
|
|
160
|
+
|
|
161
|
+
// Detect embed type from URL
|
|
162
|
+
const embedType = computed(() => {
|
|
163
|
+
if (props.src.includes('youtube.com') || props.src.includes('youtu.be')) {
|
|
164
|
+
return 'youtube';
|
|
165
|
+
}
|
|
166
|
+
if (props.src.includes('vimeo.com')) {
|
|
167
|
+
return 'vimeo';
|
|
168
|
+
}
|
|
169
|
+
return 'direct';
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Generate embed URL
|
|
173
|
+
const embedUrl = computed(() => {
|
|
174
|
+
const autoplayParam = props.autoplay ? '1' : '0';
|
|
175
|
+
|
|
176
|
+
if (embedType.value === 'youtube') {
|
|
177
|
+
let videoId = '';
|
|
178
|
+
if (props.src.includes('youtu.be/')) {
|
|
179
|
+
videoId = props.src.split('youtu.be/')[1]?.split('?')[0];
|
|
180
|
+
} else if (props.src.includes('v=')) {
|
|
181
|
+
videoId = props.src.split('v=')[1]?.split('&')[0];
|
|
182
|
+
} else if (props.src.includes('embed/')) {
|
|
183
|
+
videoId = props.src.split('embed/')[1]?.split('?')[0];
|
|
184
|
+
}
|
|
185
|
+
return `https://www.youtube.com/embed/${videoId}?autoplay=${autoplayParam}&rel=0`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (embedType.value === 'vimeo') {
|
|
189
|
+
const videoId = props.src.split('vimeo.com/')[1]?.split('?')[0];
|
|
190
|
+
return `https://player.vimeo.com/video/${videoId}?autoplay=${autoplayParam}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return props.src;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Handle close - pause video
|
|
197
|
+
const handleClose = () => {
|
|
198
|
+
if (videoRef.value) {
|
|
199
|
+
videoRef.value.pause();
|
|
200
|
+
}
|
|
201
|
+
emit('close');
|
|
202
|
+
};
|
|
203
|
+
</script>
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<UModal
|
|
3
|
+
v-model:open="isOpen"
|
|
4
|
+
:fullscreen="fullscreen"
|
|
5
|
+
:ui="modalUi"
|
|
6
|
+
>
|
|
7
|
+
<!-- Trigger Slot -->
|
|
8
|
+
<slot />
|
|
9
|
+
|
|
10
|
+
<!-- Modal Content -->
|
|
11
|
+
<template #body>
|
|
12
|
+
<div class="relative">
|
|
13
|
+
<!-- Close Button -->
|
|
14
|
+
<UButton
|
|
15
|
+
icon="i-lucide-x"
|
|
16
|
+
color="neutral"
|
|
17
|
+
variant="ghost"
|
|
18
|
+
:size="fullscreen ? 'lg' : 'md'"
|
|
19
|
+
class="absolute z-10 rounded-full"
|
|
20
|
+
:class="closeButtonClasses"
|
|
21
|
+
aria-label="Close"
|
|
22
|
+
@click="isOpen = false"
|
|
23
|
+
/>
|
|
24
|
+
|
|
25
|
+
<!-- Content -->
|
|
26
|
+
<slot name="content" :close="close" />
|
|
27
|
+
|
|
28
|
+
<!-- Footer (optional) -->
|
|
29
|
+
<div v-if="$slots.footer || title || description" class="px-5 py-4 bg-neutral-900 rounded-b-lg">
|
|
30
|
+
<slot name="footer">
|
|
31
|
+
<h4 v-if="title" class="text-white font-medium">{{ title }}</h4>
|
|
32
|
+
<p v-if="description" class="mt-1 text-sm text-neutral-400">{{ description }}</p>
|
|
33
|
+
</slot>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
37
|
+
</UModal>
|
|
38
|
+
</template>
|
|
39
|
+
|
|
40
|
+
<script setup>
|
|
41
|
+
const props = defineProps({
|
|
42
|
+
/** Modal title (shown in footer) */
|
|
43
|
+
title: {
|
|
44
|
+
type: String,
|
|
45
|
+
default: '',
|
|
46
|
+
},
|
|
47
|
+
/** Modal description (shown in footer) */
|
|
48
|
+
description: {
|
|
49
|
+
type: String,
|
|
50
|
+
default: '',
|
|
51
|
+
},
|
|
52
|
+
/** Modal size */
|
|
53
|
+
size: {
|
|
54
|
+
type: String,
|
|
55
|
+
default: '4xl',
|
|
56
|
+
validator: (v) => ['sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', '7xl', 'full'].includes(v),
|
|
57
|
+
},
|
|
58
|
+
/** Fullscreen mode */
|
|
59
|
+
fullscreen: {
|
|
60
|
+
type: Boolean,
|
|
61
|
+
default: false,
|
|
62
|
+
},
|
|
63
|
+
/** Dark mode for content (affects close button styling) */
|
|
64
|
+
dark: {
|
|
65
|
+
type: Boolean,
|
|
66
|
+
default: true,
|
|
67
|
+
},
|
|
68
|
+
/** Padding for body */
|
|
69
|
+
padding: {
|
|
70
|
+
type: String,
|
|
71
|
+
default: 'p-0',
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const emit = defineEmits(['open', 'close']);
|
|
76
|
+
|
|
77
|
+
const isOpen = ref(false);
|
|
78
|
+
|
|
79
|
+
// Close function for slot
|
|
80
|
+
const close = () => {
|
|
81
|
+
isOpen.value = false;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Size classes for content slot
|
|
85
|
+
const sizeClasses = computed(() => {
|
|
86
|
+
const sizes = {
|
|
87
|
+
sm: 'max-w-sm',
|
|
88
|
+
md: 'max-w-md',
|
|
89
|
+
lg: 'max-w-lg',
|
|
90
|
+
xl: 'max-w-xl',
|
|
91
|
+
'2xl': 'max-w-2xl',
|
|
92
|
+
'3xl': 'max-w-3xl',
|
|
93
|
+
'4xl': 'max-w-4xl',
|
|
94
|
+
'5xl': 'max-w-5xl',
|
|
95
|
+
'6xl': 'max-w-6xl',
|
|
96
|
+
'7xl': 'max-w-7xl',
|
|
97
|
+
full: 'max-w-[95vw]',
|
|
98
|
+
};
|
|
99
|
+
return sizes[props.size];
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Modal UI config
|
|
103
|
+
const modalUi = computed(() => ({
|
|
104
|
+
header: 'hidden',
|
|
105
|
+
body: `${props.padding} ${props.dark ? 'bg-black' : 'bg-white dark:bg-neutral-900'}`,
|
|
106
|
+
footer: 'hidden',
|
|
107
|
+
overlay: 'bg-black/80 backdrop-blur-sm',
|
|
108
|
+
content: `ring-neutral-200/50 dark:ring-neutral-800 ${props.fullscreen ? '' : sizeClasses.value}`,
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
// Close button classes based on mode and fullscreen
|
|
112
|
+
const closeButtonClasses = computed(() => {
|
|
113
|
+
if (props.fullscreen) {
|
|
114
|
+
return 'fixed top-4 right-4 !bg-white/10 backdrop-blur-sm hover:!bg-white/20 !text-white';
|
|
115
|
+
}
|
|
116
|
+
if (props.dark) {
|
|
117
|
+
return 'top-3 right-3 !bg-black/50 hover:!bg-black/70 !text-white/80 hover:!text-white';
|
|
118
|
+
}
|
|
119
|
+
return 'top-3 right-3 !bg-neutral-100 hover:!bg-neutral-200 dark:!bg-neutral-800 dark:hover:!bg-neutral-700 !text-neutral-500 dark:!text-neutral-400';
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Emit events
|
|
123
|
+
watch(isOpen, (open) => {
|
|
124
|
+
if (open) {
|
|
125
|
+
emit('open');
|
|
126
|
+
} else {
|
|
127
|
+
emit('close');
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Expose close method
|
|
132
|
+
defineExpose({ close, isOpen });
|
|
133
|
+
</script>
|