@xenterprises/nuxt-x-marketing 0.0.24 → 1.0.1
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/.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/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 +471 -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 +515 -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 +669 -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 +187 -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/CodeBlock/index.vue +69 -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 +181 -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 +14 -8
- package/package.json +24 -10
- 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,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,187 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<article class="blog-detail">
|
|
3
|
+
<!-- Hero -->
|
|
4
|
+
<header class="mb-16 lg:mb-20">
|
|
5
|
+
<div class="max-w-3xl mx-auto text-center mb-10">
|
|
6
|
+
<UBadge
|
|
7
|
+
v-if="post.category"
|
|
8
|
+
:label="post.category"
|
|
9
|
+
color="primary"
|
|
10
|
+
variant="subtle"
|
|
11
|
+
class="mb-6"
|
|
12
|
+
/>
|
|
13
|
+
<h1
|
|
14
|
+
class="text-3xl sm:text-4xl lg:text-5xl font-bold leading-tight tracking-tight text-neutral-900 dark:text-white"
|
|
15
|
+
>
|
|
16
|
+
{{ post.title }}
|
|
17
|
+
</h1>
|
|
18
|
+
<p
|
|
19
|
+
v-if="post.description"
|
|
20
|
+
class="mt-6 text-lg sm:text-xl text-neutral-600 dark:text-neutral-400 leading-relaxed max-w-2xl mx-auto"
|
|
21
|
+
>
|
|
22
|
+
{{ post.description }}
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
<!-- Meta -->
|
|
26
|
+
<div
|
|
27
|
+
class="mt-8 flex items-center justify-center gap-3 sm:gap-4 flex-wrap text-sm"
|
|
28
|
+
>
|
|
29
|
+
<!-- Author -->
|
|
30
|
+
<div v-if="post.author" class="flex items-center gap-2.5">
|
|
31
|
+
<img
|
|
32
|
+
v-if="post.author.avatar"
|
|
33
|
+
:src="post.author.avatar"
|
|
34
|
+
:alt="post.author.name"
|
|
35
|
+
class="w-10 h-10 rounded-full object-cover ring-2 ring-white dark:ring-neutral-800"
|
|
36
|
+
/>
|
|
37
|
+
<span class="font-medium text-neutral-900 dark:text-white">
|
|
38
|
+
{{ post.author.name }}
|
|
39
|
+
</span>
|
|
40
|
+
</div>
|
|
41
|
+
<span class="text-neutral-300 dark:text-neutral-600">•</span>
|
|
42
|
+
<time
|
|
43
|
+
class="text-neutral-500 dark:text-neutral-400"
|
|
44
|
+
:datetime="post.date"
|
|
45
|
+
>
|
|
46
|
+
{{ formattedDate }}
|
|
47
|
+
</time>
|
|
48
|
+
<span class="text-neutral-300 dark:text-neutral-600">•</span>
|
|
49
|
+
<span class="text-neutral-500 dark:text-neutral-400">{{
|
|
50
|
+
post.readingTime
|
|
51
|
+
}}</span>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<!-- Featured Image -->
|
|
56
|
+
<figure
|
|
57
|
+
v-if="post.img?.src"
|
|
58
|
+
class="aspect-2/1 sm:aspect-21/9 rounded-2xl lg:rounded-3xl overflow-hidden shadow-xl"
|
|
59
|
+
>
|
|
60
|
+
<img
|
|
61
|
+
:src="post.img.src"
|
|
62
|
+
:alt="post.img.alt || post.title"
|
|
63
|
+
class="w-full h-full object-cover"
|
|
64
|
+
/>
|
|
65
|
+
</figure>
|
|
66
|
+
</header>
|
|
67
|
+
|
|
68
|
+
<!-- Content Grid -->
|
|
69
|
+
<div class="grid lg:grid-cols-[1fr_300px] gap-12 lg:gap-16">
|
|
70
|
+
<!-- Main Content -->
|
|
71
|
+
<div class="min-w-0">
|
|
72
|
+
<!-- Prose Content -->
|
|
73
|
+
<div
|
|
74
|
+
class="prose prose-lg prose-neutral dark:prose-invert max-w-none prose-headings:scroll-mt-20 prose-a:text-primary-600 dark:prose-a:text-primary-400 prose-a:no-underline hover:prose-a:underline prose-pre:bg-neutral-900 dark:prose-pre:bg-neutral-950 prose-pre:border prose-pre:border-neutral-800 prose-img:rounded-xl"
|
|
75
|
+
>
|
|
76
|
+
<slot />
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<!-- Tags -->
|
|
80
|
+
<div
|
|
81
|
+
v-if="post.tags?.length"
|
|
82
|
+
class="mt-16 pt-10 border-t border-neutral-200 dark:border-neutral-800"
|
|
83
|
+
>
|
|
84
|
+
<h4
|
|
85
|
+
class="text-xs font-semibold uppercase tracking-wider text-neutral-500 mb-4"
|
|
86
|
+
>
|
|
87
|
+
Tags
|
|
88
|
+
</h4>
|
|
89
|
+
<div class="flex flex-wrap gap-2">
|
|
90
|
+
<UBadge
|
|
91
|
+
v-for="tag in post.tags"
|
|
92
|
+
:key="tag"
|
|
93
|
+
:label="tag"
|
|
94
|
+
variant="subtle"
|
|
95
|
+
color="neutral"
|
|
96
|
+
size="md"
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<!-- Author Bio -->
|
|
102
|
+
<XMarkBlogAuthor
|
|
103
|
+
v-if="post.author"
|
|
104
|
+
:author="post.author"
|
|
105
|
+
variant="full"
|
|
106
|
+
class="mt-12"
|
|
107
|
+
/>
|
|
108
|
+
|
|
109
|
+
<!-- Navigation -->
|
|
110
|
+
<XMarkBlogNavigation
|
|
111
|
+
v-if="previousPost || nextPost"
|
|
112
|
+
:previous="previousPost"
|
|
113
|
+
:next="nextPost"
|
|
114
|
+
class="mt-12"
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<!-- Sidebar -->
|
|
119
|
+
<aside class="hidden lg:block">
|
|
120
|
+
<div class="sticky top-24 space-y-8">
|
|
121
|
+
<!-- Table of Contents -->
|
|
122
|
+
<div
|
|
123
|
+
v-if="toc?.length"
|
|
124
|
+
class="p-5 rounded-xl bg-neutral-50 dark:bg-neutral-900/50 border border-neutral-200/60 dark:border-neutral-800"
|
|
125
|
+
>
|
|
126
|
+
<h4
|
|
127
|
+
class="font-semibold text-neutral-900 dark:text-white mb-4 text-sm"
|
|
128
|
+
>
|
|
129
|
+
On this page
|
|
130
|
+
</h4>
|
|
131
|
+
<nav class="space-y-1">
|
|
132
|
+
<a
|
|
133
|
+
v-for="item in toc"
|
|
134
|
+
:key="item.id"
|
|
135
|
+
:href="`#${item.id}`"
|
|
136
|
+
class="block py-1.5 text-sm text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors leading-snug"
|
|
137
|
+
:class="{ 'pl-4 text-[13px]': item.depth > 2 }"
|
|
138
|
+
>
|
|
139
|
+
{{ item.text }}
|
|
140
|
+
</a>
|
|
141
|
+
</nav>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<!-- Newsletter CTA -->
|
|
145
|
+
<slot name="sidebar">
|
|
146
|
+
<XMarkNewsletterForm
|
|
147
|
+
variant="compact"
|
|
148
|
+
title="Subscribe to our newsletter"
|
|
149
|
+
description="Get the latest posts and insights directly to your inbox."
|
|
150
|
+
/>
|
|
151
|
+
</slot>
|
|
152
|
+
</div>
|
|
153
|
+
</aside>
|
|
154
|
+
</div>
|
|
155
|
+
</article>
|
|
156
|
+
</template>
|
|
157
|
+
|
|
158
|
+
<script setup>
|
|
159
|
+
const props = defineProps({
|
|
160
|
+
post: {
|
|
161
|
+
type: Object,
|
|
162
|
+
required: true,
|
|
163
|
+
},
|
|
164
|
+
previousPost: {
|
|
165
|
+
type: Object,
|
|
166
|
+
default: null,
|
|
167
|
+
},
|
|
168
|
+
nextPost: {
|
|
169
|
+
type: Object,
|
|
170
|
+
default: null,
|
|
171
|
+
},
|
|
172
|
+
toc: {
|
|
173
|
+
type: Array,
|
|
174
|
+
default: () => [],
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const formattedDate = computed(() => {
|
|
179
|
+
if (!props.post.date) return "";
|
|
180
|
+
const date = new Date(props.post.date);
|
|
181
|
+
return date.toLocaleDateString("en-US", {
|
|
182
|
+
year: "numeric",
|
|
183
|
+
month: "long",
|
|
184
|
+
day: "numeric",
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
</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>
|