@xenterprises/nuxt-x-marketing 0.0.23 → 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 +6 -13
- package/package.json +21 -9
- package/.editorconfig +0 -12
- package/app/assets/css/main.css +0 -2
- package/app/components/X/Marketing/Sections/EmailSubscription/index.vue +0 -129
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
:class="[
|
|
4
|
+
'rounded-2xl p-6',
|
|
5
|
+
bgClasses
|
|
6
|
+
]"
|
|
7
|
+
>
|
|
8
|
+
<!-- Icon -->
|
|
9
|
+
<div v-if="icon && variant !== 'compact'" class="mb-4">
|
|
10
|
+
<UIcon :name="icon" class="w-8 h-8 text-primary-500" />
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<!-- Title -->
|
|
14
|
+
<h4 :class="titleClasses">{{ title }}</h4>
|
|
15
|
+
|
|
16
|
+
<!-- Description -->
|
|
17
|
+
<p
|
|
18
|
+
v-if="description"
|
|
19
|
+
:class="[
|
|
20
|
+
'mt-2 text-neutral-600 dark:text-neutral-400',
|
|
21
|
+
variant === 'compact' ? 'text-sm' : 'text-base'
|
|
22
|
+
]"
|
|
23
|
+
>
|
|
24
|
+
{{ description }}
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
<!-- Form -->
|
|
28
|
+
<form @submit.prevent="handleSubmit" class="mt-4">
|
|
29
|
+
<div
|
|
30
|
+
:class="[
|
|
31
|
+
variant === 'inline' ? 'flex gap-2' : 'space-y-3'
|
|
32
|
+
]"
|
|
33
|
+
>
|
|
34
|
+
<UInput
|
|
35
|
+
v-model="email"
|
|
36
|
+
type="email"
|
|
37
|
+
:placeholder="placeholder"
|
|
38
|
+
:size="variant === 'compact' ? 'sm' : 'md'"
|
|
39
|
+
:disabled="loading"
|
|
40
|
+
required
|
|
41
|
+
:class="variant === 'inline' ? 'flex-1' : 'w-full'"
|
|
42
|
+
/>
|
|
43
|
+
<UButton
|
|
44
|
+
type="submit"
|
|
45
|
+
:loading="loading"
|
|
46
|
+
:size="variant === 'compact' ? 'sm' : 'md'"
|
|
47
|
+
:block="variant !== 'inline'"
|
|
48
|
+
:color="button?.color || 'primary'"
|
|
49
|
+
>
|
|
50
|
+
{{ button?.label || 'Subscribe' }}
|
|
51
|
+
</UButton>
|
|
52
|
+
</div>
|
|
53
|
+
</form>
|
|
54
|
+
|
|
55
|
+
<!-- Success Message -->
|
|
56
|
+
<Transition
|
|
57
|
+
enter-active-class="transition-all duration-200"
|
|
58
|
+
enter-from-class="opacity-0 -translate-y-2"
|
|
59
|
+
leave-active-class="transition-all duration-200"
|
|
60
|
+
leave-to-class="opacity-0"
|
|
61
|
+
>
|
|
62
|
+
<p
|
|
63
|
+
v-if="success"
|
|
64
|
+
class="mt-3 text-sm text-green-600 dark:text-green-400 flex items-center gap-2"
|
|
65
|
+
>
|
|
66
|
+
<UIcon name="i-lucide-check-circle" class="w-4 h-4" />
|
|
67
|
+
{{ successMessage }}
|
|
68
|
+
</p>
|
|
69
|
+
</Transition>
|
|
70
|
+
|
|
71
|
+
<!-- Error Message -->
|
|
72
|
+
<Transition
|
|
73
|
+
enter-active-class="transition-all duration-200"
|
|
74
|
+
enter-from-class="opacity-0 -translate-y-2"
|
|
75
|
+
leave-active-class="transition-all duration-200"
|
|
76
|
+
leave-to-class="opacity-0"
|
|
77
|
+
>
|
|
78
|
+
<p
|
|
79
|
+
v-if="error"
|
|
80
|
+
class="mt-3 text-sm text-red-600 dark:text-red-400 flex items-center gap-2"
|
|
81
|
+
>
|
|
82
|
+
<UIcon name="i-lucide-alert-circle" class="w-4 h-4" />
|
|
83
|
+
{{ error }}
|
|
84
|
+
</p>
|
|
85
|
+
</Transition>
|
|
86
|
+
|
|
87
|
+
<!-- Privacy Note -->
|
|
88
|
+
<p
|
|
89
|
+
v-if="hasPrivacy"
|
|
90
|
+
class="mt-3 text-xs text-neutral-500"
|
|
91
|
+
>
|
|
92
|
+
{{ privacyText }}
|
|
93
|
+
</p>
|
|
94
|
+
</div>
|
|
95
|
+
</template>
|
|
96
|
+
|
|
97
|
+
<script setup>
|
|
98
|
+
const props = defineProps({
|
|
99
|
+
/** CTA title */
|
|
100
|
+
title: {
|
|
101
|
+
type: String,
|
|
102
|
+
default: 'Subscribe to our newsletter',
|
|
103
|
+
},
|
|
104
|
+
/** CTA description */
|
|
105
|
+
description: {
|
|
106
|
+
type: String,
|
|
107
|
+
default: 'Get the latest posts delivered right to your inbox.',
|
|
108
|
+
},
|
|
109
|
+
/** Button config { label: string, color?: string } */
|
|
110
|
+
button: {
|
|
111
|
+
type: Object,
|
|
112
|
+
default: () => ({ label: 'Subscribe' }),
|
|
113
|
+
},
|
|
114
|
+
/** Input placeholder */
|
|
115
|
+
placeholder: {
|
|
116
|
+
type: String,
|
|
117
|
+
default: 'Enter your email',
|
|
118
|
+
},
|
|
119
|
+
/** Style variant */
|
|
120
|
+
variant: {
|
|
121
|
+
type: String,
|
|
122
|
+
default: 'default',
|
|
123
|
+
validator: (v) => ['default', 'featured', 'inline', 'compact'].includes(v),
|
|
124
|
+
},
|
|
125
|
+
/** Optional icon */
|
|
126
|
+
icon: {
|
|
127
|
+
type: String,
|
|
128
|
+
default: 'i-lucide-mail',
|
|
129
|
+
},
|
|
130
|
+
/** Has privacy note */
|
|
131
|
+
hasPrivacy: {
|
|
132
|
+
type: Boolean,
|
|
133
|
+
default: true,
|
|
134
|
+
},
|
|
135
|
+
/** Privacy note text */
|
|
136
|
+
privacyText: {
|
|
137
|
+
type: String,
|
|
138
|
+
default: 'No spam. Unsubscribe anytime.',
|
|
139
|
+
},
|
|
140
|
+
/** Success message */
|
|
141
|
+
successMessage: {
|
|
142
|
+
type: String,
|
|
143
|
+
default: 'Thanks for subscribing!',
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const emit = defineEmits(['submit']);
|
|
148
|
+
|
|
149
|
+
const email = ref('');
|
|
150
|
+
const loading = ref(false);
|
|
151
|
+
const success = ref(false);
|
|
152
|
+
const error = ref('');
|
|
153
|
+
|
|
154
|
+
const bgClasses = computed(() => {
|
|
155
|
+
const variants = {
|
|
156
|
+
default: 'bg-neutral-100 dark:bg-neutral-900',
|
|
157
|
+
featured: 'bg-primary-50 dark:bg-primary-950',
|
|
158
|
+
inline: 'bg-neutral-100 dark:bg-neutral-900',
|
|
159
|
+
compact: 'bg-neutral-100 dark:bg-neutral-900',
|
|
160
|
+
};
|
|
161
|
+
return variants[props.variant];
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const titleClasses = computed(() => {
|
|
165
|
+
const base = 'font-semibold text-neutral-900 dark:text-white';
|
|
166
|
+
const sizes = {
|
|
167
|
+
default: 'text-lg',
|
|
168
|
+
featured: 'text-xl',
|
|
169
|
+
inline: 'text-lg',
|
|
170
|
+
compact: 'text-base',
|
|
171
|
+
};
|
|
172
|
+
return `${base} ${sizes[props.variant]}`;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const handleSubmit = async () => {
|
|
176
|
+
if (!email.value) return;
|
|
177
|
+
|
|
178
|
+
loading.value = true;
|
|
179
|
+
error.value = '';
|
|
180
|
+
success.value = false;
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
emit('submit', email.value);
|
|
184
|
+
success.value = true;
|
|
185
|
+
email.value = '';
|
|
186
|
+
|
|
187
|
+
// Auto-hide success message
|
|
188
|
+
setTimeout(() => {
|
|
189
|
+
success.value = false;
|
|
190
|
+
}, 5000);
|
|
191
|
+
} catch (e) {
|
|
192
|
+
error.value = e.message || 'Something went wrong. Please try again.';
|
|
193
|
+
} finally {
|
|
194
|
+
loading.value = false;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
</script>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<article class="group">
|
|
3
|
+
<NuxtLink :to="`/blog/${post.slug}`" class="block">
|
|
4
|
+
<!-- Image -->
|
|
5
|
+
<figure v-if="post.img?.src" class="aspect-[16/9] rounded-xl overflow-hidden mb-4 xHover-zoom shadow-md transition-shadow duration-300 group-hover:shadow-xl">
|
|
6
|
+
<img
|
|
7
|
+
:src="post.img.src"
|
|
8
|
+
:alt="post.img.alt || post.title"
|
|
9
|
+
class="w-full h-full object-cover"
|
|
10
|
+
/>
|
|
11
|
+
</figure>
|
|
12
|
+
|
|
13
|
+
<!-- Meta -->
|
|
14
|
+
<div class="flex items-center gap-3 mb-3">
|
|
15
|
+
<UBadge
|
|
16
|
+
v-if="hasCategory && post.category"
|
|
17
|
+
:label="post.category"
|
|
18
|
+
variant="subtle"
|
|
19
|
+
/>
|
|
20
|
+
<time
|
|
21
|
+
:datetime="post.date"
|
|
22
|
+
class="text-sm text-neutral-500"
|
|
23
|
+
>
|
|
24
|
+
{{ formattedDate }}
|
|
25
|
+
</time>
|
|
26
|
+
<span class="text-sm text-neutral-400">·</span>
|
|
27
|
+
<span class="text-sm text-neutral-500">{{ post.readingTime }} min read</span>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<!-- Title -->
|
|
31
|
+
<h3 class="xText-title text-neutral-900 dark:text-white group-hover:text-primary-500 transition-colors">
|
|
32
|
+
{{ post.title }}
|
|
33
|
+
</h3>
|
|
34
|
+
|
|
35
|
+
<!-- Excerpt -->
|
|
36
|
+
<p
|
|
37
|
+
v-if="hasExcerpt && post.description"
|
|
38
|
+
class="mt-2 text-neutral-600 dark:text-neutral-400 line-clamp-2"
|
|
39
|
+
>
|
|
40
|
+
{{ post.description }}
|
|
41
|
+
</p>
|
|
42
|
+
|
|
43
|
+
<!-- Author -->
|
|
44
|
+
<div v-if="hasAuthor && post.author" class="mt-4 flex items-center gap-3">
|
|
45
|
+
<img
|
|
46
|
+
v-if="post.author.avatar"
|
|
47
|
+
:src="post.author.avatar"
|
|
48
|
+
:alt="post.author.name"
|
|
49
|
+
class="w-8 h-8 rounded-full object-cover"
|
|
50
|
+
/>
|
|
51
|
+
<div
|
|
52
|
+
v-else
|
|
53
|
+
class="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center"
|
|
54
|
+
>
|
|
55
|
+
<span class="text-primary-600 dark:text-primary-400 font-medium text-sm">
|
|
56
|
+
{{ post.author.name.charAt(0) }}
|
|
57
|
+
</span>
|
|
58
|
+
</div>
|
|
59
|
+
<span class="text-sm font-medium text-neutral-900 dark:text-white">
|
|
60
|
+
{{ post.author.name }}
|
|
61
|
+
</span>
|
|
62
|
+
</div>
|
|
63
|
+
</NuxtLink>
|
|
64
|
+
|
|
65
|
+
<!-- Full Width Button -->
|
|
66
|
+
<UButton
|
|
67
|
+
v-if="button?.label"
|
|
68
|
+
:to="`/blog/${post.slug}`"
|
|
69
|
+
:label="button.label"
|
|
70
|
+
:color="button.color || 'neutral'"
|
|
71
|
+
:variant="button.variant || 'outline'"
|
|
72
|
+
:icon="button.icon"
|
|
73
|
+
block
|
|
74
|
+
class="mt-4 justify-center"
|
|
75
|
+
/>
|
|
76
|
+
</article>
|
|
77
|
+
</template>
|
|
78
|
+
|
|
79
|
+
<script setup>
|
|
80
|
+
const props = defineProps({
|
|
81
|
+
/** Blog post object with img: { src, alt? } */
|
|
82
|
+
post: {
|
|
83
|
+
type: Object,
|
|
84
|
+
required: true,
|
|
85
|
+
},
|
|
86
|
+
/** Has category badge */
|
|
87
|
+
hasCategory: {
|
|
88
|
+
type: Boolean,
|
|
89
|
+
default: true,
|
|
90
|
+
},
|
|
91
|
+
/** Has author info */
|
|
92
|
+
hasAuthor: {
|
|
93
|
+
type: Boolean,
|
|
94
|
+
default: true,
|
|
95
|
+
},
|
|
96
|
+
/** Has description */
|
|
97
|
+
hasExcerpt: {
|
|
98
|
+
type: Boolean,
|
|
99
|
+
default: true,
|
|
100
|
+
},
|
|
101
|
+
/** Optional full-width button { label, color?, variant?, icon? } */
|
|
102
|
+
button: {
|
|
103
|
+
type: Object,
|
|
104
|
+
default: null,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const formattedDate = computed(() => {
|
|
109
|
+
if (!props.post.date) return '';
|
|
110
|
+
const date = new Date(props.post.date);
|
|
111
|
+
return date.toLocaleDateString('en-US', {
|
|
112
|
+
year: 'numeric',
|
|
113
|
+
month: 'short',
|
|
114
|
+
day: 'numeric',
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
</script>
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<article>
|
|
3
|
+
<!-- Hero -->
|
|
4
|
+
<header class="mb-12">
|
|
5
|
+
<div class="max-w-3xl mx-auto text-center mb-8">
|
|
6
|
+
<UBadge
|
|
7
|
+
v-if="post.category"
|
|
8
|
+
:label="post.category"
|
|
9
|
+
class="mb-4"
|
|
10
|
+
/>
|
|
11
|
+
<h1 class="xText-headline xText-balance text-neutral-900 dark:text-white">
|
|
12
|
+
{{ post.title }}
|
|
13
|
+
</h1>
|
|
14
|
+
<p
|
|
15
|
+
v-if="post.description"
|
|
16
|
+
class="mt-4 text-xl text-neutral-600 dark:text-neutral-400"
|
|
17
|
+
>
|
|
18
|
+
{{ post.description }}
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
<!-- Meta -->
|
|
22
|
+
<div class="mt-6 flex items-center justify-center gap-4 flex-wrap">
|
|
23
|
+
<!-- Author -->
|
|
24
|
+
<div v-if="post.author" class="flex items-center gap-2">
|
|
25
|
+
<img
|
|
26
|
+
v-if="post.author.avatar"
|
|
27
|
+
:src="post.author.avatar"
|
|
28
|
+
:alt="post.author.name"
|
|
29
|
+
class="w-8 h-8 rounded-full object-cover"
|
|
30
|
+
/>
|
|
31
|
+
<span class="font-medium text-neutral-900 dark:text-white">
|
|
32
|
+
{{ post.author.name }}
|
|
33
|
+
</span>
|
|
34
|
+
</div>
|
|
35
|
+
<span class="text-neutral-300 dark:text-neutral-700">·</span>
|
|
36
|
+
<time class="text-neutral-500" :datetime="post.date">
|
|
37
|
+
{{ formattedDate }}
|
|
38
|
+
</time>
|
|
39
|
+
<span class="text-neutral-300 dark:text-neutral-700">·</span>
|
|
40
|
+
<span class="text-neutral-500">{{ post.readingTime }} min read</span>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<!-- Featured Image -->
|
|
45
|
+
<figure
|
|
46
|
+
v-if="post.img?.src"
|
|
47
|
+
class="aspect-[21/9] rounded-2xl overflow-hidden"
|
|
48
|
+
>
|
|
49
|
+
<img
|
|
50
|
+
:src="post.img.src"
|
|
51
|
+
:alt="post.img.alt || post.title"
|
|
52
|
+
class="w-full h-full object-cover"
|
|
53
|
+
/>
|
|
54
|
+
</figure>
|
|
55
|
+
</header>
|
|
56
|
+
|
|
57
|
+
<!-- Content Grid -->
|
|
58
|
+
<div class="grid lg:grid-cols-[1fr_280px] gap-12">
|
|
59
|
+
<!-- Main Content -->
|
|
60
|
+
<div class="max-w-3xl">
|
|
61
|
+
<!-- Prose -->
|
|
62
|
+
<div class="prose prose-lg dark:prose-invert prose-primary max-w-none">
|
|
63
|
+
<slot />
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<!-- Tags -->
|
|
67
|
+
<div v-if="post.tags?.length" class="mt-12 pt-8 border-t border-neutral-200 dark:border-neutral-800">
|
|
68
|
+
<div class="flex flex-wrap gap-2">
|
|
69
|
+
<UBadge
|
|
70
|
+
v-for="tag in post.tags"
|
|
71
|
+
:key="tag"
|
|
72
|
+
:label="tag"
|
|
73
|
+
variant="outline"
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<!-- Author Bio -->
|
|
79
|
+
<div
|
|
80
|
+
v-if="post.author?.bio"
|
|
81
|
+
class="mt-12 flex gap-6 p-6 rounded-2xl bg-neutral-100 dark:bg-neutral-900"
|
|
82
|
+
>
|
|
83
|
+
<img
|
|
84
|
+
v-if="post.author.avatar"
|
|
85
|
+
:src="post.author.avatar"
|
|
86
|
+
:alt="post.author.name"
|
|
87
|
+
class="w-20 h-20 rounded-full object-cover flex-shrink-0"
|
|
88
|
+
/>
|
|
89
|
+
<div>
|
|
90
|
+
<p class="font-semibold text-lg text-neutral-900 dark:text-white">
|
|
91
|
+
{{ post.author.name }}
|
|
92
|
+
</p>
|
|
93
|
+
<p v-if="post.author.title" class="text-sm text-neutral-500 mb-3">
|
|
94
|
+
{{ post.author.title }}
|
|
95
|
+
</p>
|
|
96
|
+
<p class="text-neutral-600 dark:text-neutral-400">
|
|
97
|
+
{{ post.author.bio }}
|
|
98
|
+
</p>
|
|
99
|
+
<div v-if="post.author.social" class="flex gap-2 mt-4">
|
|
100
|
+
<UButton
|
|
101
|
+
v-for="(url, platform) in post.author.social"
|
|
102
|
+
:key="platform"
|
|
103
|
+
:to="url"
|
|
104
|
+
:icon="getSocialIcon(platform)"
|
|
105
|
+
variant="ghost"
|
|
106
|
+
color="neutral"
|
|
107
|
+
size="sm"
|
|
108
|
+
target="_blank"
|
|
109
|
+
:aria-label="platform"
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<!-- Navigation -->
|
|
116
|
+
<nav
|
|
117
|
+
v-if="previousPost || nextPost"
|
|
118
|
+
class="mt-12 pt-8 border-t border-neutral-200 dark:border-neutral-800 grid sm:grid-cols-2 gap-4"
|
|
119
|
+
>
|
|
120
|
+
<!-- Previous -->
|
|
121
|
+
<NuxtLink
|
|
122
|
+
v-if="previousPost"
|
|
123
|
+
:to="`/blog/${previousPost.slug}`"
|
|
124
|
+
class="group flex items-center gap-4 p-4 rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-900 transition-colors"
|
|
125
|
+
>
|
|
126
|
+
<UIcon
|
|
127
|
+
name="i-lucide-arrow-left"
|
|
128
|
+
class="w-5 h-5 text-neutral-400 group-hover:text-primary-500 transition-colors"
|
|
129
|
+
/>
|
|
130
|
+
<div>
|
|
131
|
+
<span class="text-xs text-neutral-500 uppercase tracking-wide">Previous</span>
|
|
132
|
+
<p class="font-medium text-neutral-900 dark:text-white group-hover:text-primary-500 transition-colors line-clamp-1">
|
|
133
|
+
{{ previousPost.title }}
|
|
134
|
+
</p>
|
|
135
|
+
</div>
|
|
136
|
+
</NuxtLink>
|
|
137
|
+
<div v-else />
|
|
138
|
+
|
|
139
|
+
<!-- Next -->
|
|
140
|
+
<NuxtLink
|
|
141
|
+
v-if="nextPost"
|
|
142
|
+
:to="`/blog/${nextPost.slug}`"
|
|
143
|
+
class="group flex items-center justify-end gap-4 p-4 rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-900 transition-colors text-right"
|
|
144
|
+
>
|
|
145
|
+
<div>
|
|
146
|
+
<span class="text-xs text-neutral-500 uppercase tracking-wide">Next</span>
|
|
147
|
+
<p class="font-medium text-neutral-900 dark:text-white group-hover:text-primary-500 transition-colors line-clamp-1">
|
|
148
|
+
{{ nextPost.title }}
|
|
149
|
+
</p>
|
|
150
|
+
</div>
|
|
151
|
+
<UIcon
|
|
152
|
+
name="i-lucide-arrow-right"
|
|
153
|
+
class="w-5 h-5 text-neutral-400 group-hover:text-primary-500 transition-colors"
|
|
154
|
+
/>
|
|
155
|
+
</NuxtLink>
|
|
156
|
+
</nav>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<!-- Sidebar -->
|
|
160
|
+
<aside class="hidden lg:block">
|
|
161
|
+
<div class="sticky top-24 space-y-8">
|
|
162
|
+
<!-- Table of Contents -->
|
|
163
|
+
<div v-if="toc?.length">
|
|
164
|
+
<h4 class="font-medium text-neutral-900 dark:text-white mb-4">On this page</h4>
|
|
165
|
+
<nav class="space-y-2">
|
|
166
|
+
<a
|
|
167
|
+
v-for="item in toc"
|
|
168
|
+
:key="item.id"
|
|
169
|
+
:href="`#${item.id}`"
|
|
170
|
+
class="block text-sm text-neutral-500 hover:text-primary-500 transition-colors"
|
|
171
|
+
:class="{ 'pl-4': item.depth > 2 }"
|
|
172
|
+
>
|
|
173
|
+
{{ item.text }}
|
|
174
|
+
</a>
|
|
175
|
+
</nav>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<!-- Newsletter CTA -->
|
|
179
|
+
<slot name="sidebar">
|
|
180
|
+
<XMarkNewsletterForm
|
|
181
|
+
variant="compact"
|
|
182
|
+
title="Subscribe"
|
|
183
|
+
description="Get notified about new posts."
|
|
184
|
+
/>
|
|
185
|
+
</slot>
|
|
186
|
+
</div>
|
|
187
|
+
</aside>
|
|
188
|
+
</div>
|
|
189
|
+
</article>
|
|
190
|
+
</template>
|
|
191
|
+
|
|
192
|
+
<script setup>
|
|
193
|
+
const props = defineProps({
|
|
194
|
+
/** Blog post object with img: { src, alt? } */
|
|
195
|
+
post: {
|
|
196
|
+
type: Object,
|
|
197
|
+
required: true,
|
|
198
|
+
},
|
|
199
|
+
/** Previous post for navigation */
|
|
200
|
+
previousPost: {
|
|
201
|
+
type: Object,
|
|
202
|
+
default: null,
|
|
203
|
+
},
|
|
204
|
+
/** Next post for navigation */
|
|
205
|
+
nextPost: {
|
|
206
|
+
type: Object,
|
|
207
|
+
default: null,
|
|
208
|
+
},
|
|
209
|
+
/** Table of contents items */
|
|
210
|
+
toc: {
|
|
211
|
+
type: Array,
|
|
212
|
+
default: () => [],
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const formattedDate = computed(() => {
|
|
217
|
+
if (!props.post.date) return '';
|
|
218
|
+
const date = new Date(props.post.date);
|
|
219
|
+
return date.toLocaleDateString('en-US', {
|
|
220
|
+
year: 'numeric',
|
|
221
|
+
month: 'long',
|
|
222
|
+
day: 'numeric',
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const getSocialIcon = (platform) => {
|
|
227
|
+
const icons = {
|
|
228
|
+
twitter: 'i-lucide-twitter',
|
|
229
|
+
x: 'i-lucide-twitter',
|
|
230
|
+
linkedin: 'i-lucide-linkedin',
|
|
231
|
+
github: 'i-lucide-github',
|
|
232
|
+
website: 'i-lucide-globe',
|
|
233
|
+
instagram: 'i-lucide-instagram',
|
|
234
|
+
facebook: 'i-lucide-facebook',
|
|
235
|
+
youtube: 'i-lucide-youtube',
|
|
236
|
+
email: 'i-lucide-mail',
|
|
237
|
+
};
|
|
238
|
+
return icons[platform.toLowerCase()] || 'i-lucide-link';
|
|
239
|
+
};
|
|
240
|
+
</script>
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<!-- Filter/Search -->
|
|
4
|
+
<div v-if="hasFilters" class="flex flex-wrap gap-4 mb-8">
|
|
5
|
+
<UInput
|
|
6
|
+
v-model="search"
|
|
7
|
+
placeholder="Search posts..."
|
|
8
|
+
icon="i-lucide-search"
|
|
9
|
+
class="w-full sm:w-64"
|
|
10
|
+
/>
|
|
11
|
+
<USelectMenu
|
|
12
|
+
v-if="categories.length"
|
|
13
|
+
v-model="selectedCategory"
|
|
14
|
+
:options="categoryOptions"
|
|
15
|
+
placeholder="All Categories"
|
|
16
|
+
class="w-40"
|
|
17
|
+
/>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<!-- Grid -->
|
|
21
|
+
<div :class="gridClasses">
|
|
22
|
+
<XMarkBlogCard
|
|
23
|
+
v-for="(post, index) in paginatedPosts"
|
|
24
|
+
:key="post.slug"
|
|
25
|
+
:post="post"
|
|
26
|
+
:has-category="hasCategories"
|
|
27
|
+
:has-author="hasAuthors"
|
|
28
|
+
:button="cardButton"
|
|
29
|
+
class="xFadeUp"
|
|
30
|
+
:style="{ transitionDelay: `${index * 50}ms` }"
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<!-- Empty State -->
|
|
35
|
+
<div
|
|
36
|
+
v-if="!paginatedPosts.length"
|
|
37
|
+
class="text-center py-16"
|
|
38
|
+
>
|
|
39
|
+
<UIcon name="i-lucide-file-text" class="w-12 h-12 text-neutral-300 dark:text-neutral-700 mx-auto mb-4" />
|
|
40
|
+
<p class="text-neutral-600 dark:text-neutral-400">No posts found</p>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<!-- Pagination -->
|
|
44
|
+
<div v-if="totalPages > 1" class="mt-12 flex justify-center">
|
|
45
|
+
<UPagination
|
|
46
|
+
v-model="currentPage"
|
|
47
|
+
:total="filteredPosts.length"
|
|
48
|
+
:page-count="perPage"
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</template>
|
|
53
|
+
|
|
54
|
+
<script setup>
|
|
55
|
+
const props = defineProps({
|
|
56
|
+
/** Array of blog posts with img: { src, alt? } */
|
|
57
|
+
posts: {
|
|
58
|
+
type: Array,
|
|
59
|
+
required: true,
|
|
60
|
+
},
|
|
61
|
+
/** Grid columns */
|
|
62
|
+
columns: {
|
|
63
|
+
type: Number,
|
|
64
|
+
default: 3,
|
|
65
|
+
validator: (v) => [1, 2, 3].includes(v),
|
|
66
|
+
},
|
|
67
|
+
/** Posts per page */
|
|
68
|
+
perPage: {
|
|
69
|
+
type: Number,
|
|
70
|
+
default: 9,
|
|
71
|
+
},
|
|
72
|
+
/** Has search/filter */
|
|
73
|
+
hasFilters: {
|
|
74
|
+
type: Boolean,
|
|
75
|
+
default: false,
|
|
76
|
+
},
|
|
77
|
+
/** Has category badges */
|
|
78
|
+
hasCategories: {
|
|
79
|
+
type: Boolean,
|
|
80
|
+
default: true,
|
|
81
|
+
},
|
|
82
|
+
/** Has author info */
|
|
83
|
+
hasAuthors: {
|
|
84
|
+
type: Boolean,
|
|
85
|
+
default: true,
|
|
86
|
+
},
|
|
87
|
+
/** Available categories */
|
|
88
|
+
categories: {
|
|
89
|
+
type: Array,
|
|
90
|
+
default: () => [],
|
|
91
|
+
},
|
|
92
|
+
/** Optional button for all cards { label, color?, variant?, icon? } */
|
|
93
|
+
cardButton: {
|
|
94
|
+
type: Object,
|
|
95
|
+
default: null,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const search = ref('');
|
|
100
|
+
const selectedCategory = ref('');
|
|
101
|
+
const currentPage = ref(1);
|
|
102
|
+
|
|
103
|
+
const categoryOptions = computed(() => {
|
|
104
|
+
return [
|
|
105
|
+
{ label: 'All Categories', value: '' },
|
|
106
|
+
...props.categories.map((cat) => ({
|
|
107
|
+
label: cat,
|
|
108
|
+
value: cat,
|
|
109
|
+
})),
|
|
110
|
+
];
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const filteredPosts = computed(() => {
|
|
114
|
+
let result = props.posts;
|
|
115
|
+
|
|
116
|
+
// Filter by search
|
|
117
|
+
if (search.value) {
|
|
118
|
+
const searchLower = search.value.toLowerCase();
|
|
119
|
+
result = result.filter((post) =>
|
|
120
|
+
post.title.toLowerCase().includes(searchLower) ||
|
|
121
|
+
post.description?.toLowerCase().includes(searchLower)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Filter by category
|
|
126
|
+
if (selectedCategory.value) {
|
|
127
|
+
result = result.filter((post) => post.category === selectedCategory.value);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return result;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const totalPages = computed(() => {
|
|
134
|
+
return Math.ceil(filteredPosts.value.length / props.perPage);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const paginatedPosts = computed(() => {
|
|
138
|
+
const start = (currentPage.value - 1) * props.perPage;
|
|
139
|
+
const end = start + props.perPage;
|
|
140
|
+
return filteredPosts.value.slice(start, end);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const gridClasses = computed(() => {
|
|
144
|
+
const cols = {
|
|
145
|
+
1: 'grid gap-8',
|
|
146
|
+
2: 'grid md:grid-cols-2 gap-8',
|
|
147
|
+
3: 'grid md:grid-cols-2 lg:grid-cols-3 gap-8',
|
|
148
|
+
};
|
|
149
|
+
return cols[props.columns];
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Reset pagination when filters change
|
|
153
|
+
watch([search, selectedCategory], () => {
|
|
154
|
+
currentPage.value = 1;
|
|
155
|
+
});
|
|
156
|
+
</script>
|