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