@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.
Files changed (114) hide show
  1. package/.playground/app.config.ts +44 -45
  2. package/.playground/app.vue +263 -0
  3. package/.playground/components/AppFooter.vue +103 -0
  4. package/.playground/components/AppHeader.vue +41 -0
  5. package/.playground/components/CodeExample.vue +70 -0
  6. package/.playground/components/ComponentPlayground.vue +117 -0
  7. package/.playground/components/DocsHeader.vue +19 -0
  8. package/.playground/layouts/default.vue +13 -3
  9. package/.playground/layouts/docs-layout.vue +161 -0
  10. package/.playground/layouts/docs.vue +11 -0
  11. package/.playground/pages/blog/author.vue +145 -0
  12. package/.playground/pages/blog/card.vue +149 -0
  13. package/.playground/pages/blog/cta.vue +131 -0
  14. package/.playground/pages/blog/detail.vue +471 -0
  15. package/.playground/pages/blog/list.vue +261 -0
  16. package/.playground/pages/blog/navigation.vue +228 -0
  17. package/.playground/pages/blog/sidebar.vue +279 -0
  18. package/.playground/pages/config.vue +289 -0
  19. package/.playground/pages/content/comparison.vue +253 -0
  20. package/.playground/pages/content/cta.vue +245 -0
  21. package/.playground/pages/content/features.vue +217 -0
  22. package/.playground/pages/content/newsletter-section.vue +260 -0
  23. package/.playground/pages/content/newsletter.vue +200 -0
  24. package/.playground/pages/content/pricing.vue +335 -0
  25. package/.playground/pages/content/testimonials.vue +188 -0
  26. package/.playground/pages/hero.vue +257 -0
  27. package/.playground/pages/index.vue +379 -2
  28. package/.playground/pages/layout/footer.vue +210 -0
  29. package/.playground/pages/layout/legal-footer.vue +121 -0
  30. package/.playground/pages/layout/navbar.vue +285 -0
  31. package/.playground/pages/layout/section.vue +266 -0
  32. package/.playground/pages/layout/x-legal.vue +199 -0
  33. package/.playground/pages/modals/feature-modal.vue +320 -0
  34. package/.playground/pages/modals/image-lightbox.vue +277 -0
  35. package/.playground/pages/modals/video-modal.vue +222 -0
  36. package/.playground/pages/notifications/announcement-bar.vue +164 -0
  37. package/.playground/pages/notifications/cookie-banner.vue +210 -0
  38. package/.playground/pages/notifications/cookie-toast.vue +143 -0
  39. package/.playground/pages/notifications/gdpr.vue +276 -0
  40. package/.playground/pages/notifications/social-proof-toast.vue +515 -0
  41. package/.playground/pages/ui/badge.vue +161 -0
  42. package/.playground/pages/ui/bg-pattern.vue +177 -0
  43. package/.playground/pages/ui/glass-card.vue +179 -0
  44. package/.playground/pages/ui/glow-divider.vue +179 -0
  45. package/.playground/pages/ui/promo-card.vue +259 -0
  46. package/.playground/pages/ui/section-stitch.vue +411 -0
  47. package/.playground/pages/utilities/back-to-top.vue +153 -0
  48. package/.playground/pages/utilities/popup-chat.vue +224 -0
  49. package/README.md +799 -164
  50. package/app/app.config.ts +88 -2
  51. package/app/app.vue +106 -11
  52. package/app/assets/css/x-marketing.css +669 -0
  53. package/app/components/X/Footer/XLegal/index.vue +63 -26
  54. package/app/components/X/Footer/index.vue +89 -89
  55. package/app/components/X/Header/App.vue +63 -0
  56. package/app/components/X/Mark/Blog/Author.vue +170 -0
  57. package/app/components/X/Mark/Blog/CTANewsletter.vue +197 -0
  58. package/app/components/X/Mark/Blog/Card.vue +117 -0
  59. package/app/components/X/Mark/Blog/Detail.vue +187 -0
  60. package/app/components/X/Mark/Blog/List.vue +156 -0
  61. package/app/components/X/Mark/Blog/Navigation.vue +100 -0
  62. package/app/components/X/Mark/Blog/Sidebar.vue +160 -0
  63. package/app/components/X/Mark/Button/BackToTop.vue +99 -0
  64. package/app/components/X/Mark/CTA/index.vue +294 -0
  65. package/app/components/X/Mark/Card/Glass.vue +64 -0
  66. package/app/components/X/Mark/Card/Promo.vue +164 -0
  67. package/app/components/X/Mark/CodeBlock/index.vue +69 -0
  68. package/app/components/X/Mark/Divider/Glow.vue +113 -0
  69. package/app/components/X/Mark/Features/index.vue +194 -0
  70. package/app/components/X/Mark/Hero/index.vue +217 -0
  71. package/app/components/X/Mark/Layout/AnnouncementBar.vue +139 -0
  72. package/app/components/X/Mark/Layout/Footer.vue +160 -0
  73. package/app/components/X/Mark/Layout/FooterLegal.vue +42 -0
  74. package/app/components/X/Mark/Layout/Navbar.vue +267 -0
  75. package/app/components/X/Mark/Modal/Chat.vue +151 -0
  76. package/app/components/X/Mark/Modal/Feature.vue +197 -0
  77. package/app/components/X/Mark/Modal/Image.vue +226 -0
  78. package/app/components/X/Mark/Modal/Video.vue +203 -0
  79. package/app/components/X/Mark/Modal/index.vue +133 -0
  80. package/app/components/X/Mark/Newsletter/Form.vue +174 -0
  81. package/app/components/X/Mark/Pattern/Bg.vue +86 -0
  82. package/app/components/X/Mark/Pricing/Comparison.vue +126 -0
  83. package/app/components/X/Mark/Pricing/Plans.vue +181 -0
  84. package/app/components/X/Mark/Privacy/CookieBanner.vue +112 -0
  85. package/app/components/X/Mark/Privacy/CookieToast.vue +43 -0
  86. package/app/components/X/Mark/Privacy/GDPR.vue +245 -0
  87. package/app/components/X/Mark/Section/Newsletter.vue +183 -0
  88. package/app/components/X/Mark/Section/Stitch.vue +156 -0
  89. package/app/components/X/Mark/Section/index.vue +151 -0
  90. package/app/components/X/Mark/SocialProof/Badge.vue +102 -0
  91. package/app/components/X/Mark/SocialProof/Testimonials.vue +326 -0
  92. package/app/components/X/Mark/SocialProof/Toast.vue +181 -0
  93. package/app/components/X/Mark/_archive/Pattern/Background.vue +83 -0
  94. package/app/components/X/Mark/_archive/Pattern/CheckBg.vue +35 -0
  95. package/app/components/X/Mark/_archive/Pattern/CircuitBg.vue +41 -0
  96. package/app/components/X/Mark/_archive/Pattern/DotsBg.vue +35 -0
  97. package/app/components/X/Mark/_archive/Pattern/GlowDivider.vue +86 -0
  98. package/app/components/X/Mark/_archive/Pattern/HexBg.vue +35 -0
  99. package/app/components/X/Mark/_archive/Pattern/PlaidBg.vue +47 -0
  100. package/app/components/X/Mark/_archive/Pattern/SectionStitch.vue +168 -0
  101. package/app/components/X/Mark/_archive/Pattern/WaveBg.vue +41 -0
  102. package/app/components/X/_archive/Newsletter/Form.vue +123 -0
  103. package/app/components/X/_archive/Newsletter/Section.vue +131 -0
  104. package/app/composables/useParallax.ts +124 -0
  105. package/app/composables/useScrollReveal.ts +92 -0
  106. package/app/pages/index.vue +210 -0
  107. package/app/plugins/marketing.client.ts +13 -0
  108. package/app/types/marketing.d.ts +268 -0
  109. package/componentList.json +511 -0
  110. package/nuxt.config.ts +14 -8
  111. package/package.json +24 -10
  112. package/.editorconfig +0 -12
  113. package/app/assets/css/main.css +0 -9
  114. 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>