@eox/pages-theme-eox 1.9.0 → 2.0.0-alpha.2

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.
@@ -0,0 +1,636 @@
1
+ <template>
2
+ <div class="blog-landing max-w-[1200px] mx-auto px-6 md:px-8">
3
+ <!-- Page Header -->
4
+ <div class="blog-landing-header" role="banner">
5
+ <div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
6
+ <div>
7
+ <h1 class="blog-landing-title">
8
+ EOX Blog
9
+ </h1>
10
+ <p class="blog-landing-subtitle">
11
+ Insights, updates, and deep dives from our geospatial experts.
12
+ </p>
13
+ </div>
14
+ </div>
15
+
16
+ <!-- Active Filter Banner -->
17
+ <div
18
+ v-if="activeTag || activeAuthor"
19
+ class="active-filter-banner"
20
+ >
21
+ <span class="filter-label">Active filter:</span>
22
+ <span
23
+ v-if="activeTag"
24
+ :style="getTagStyle(activeTag)"
25
+ class="active-filter-tag shadow-sm inline-flex items-center gap-1.5"
26
+ >
27
+ {{ activeTag }}
28
+ </span>
29
+ <span
30
+ v-if="activeAuthor"
31
+ class="active-filter-author shadow-sm inline-flex items-center gap-1.5"
32
+ >
33
+ Author: {{ getAuthorName(activeAuthor) }}
34
+ </span>
35
+ <button
36
+ @click="clearFilters"
37
+ class="clear-filter-btn"
38
+ >
39
+ <i class="mdi mdi-close-circle"></i>
40
+ <span>Clear Filter</span>
41
+ </button>
42
+ </div>
43
+ </div>
44
+
45
+ <!-- Grid of Blog Posts -->
46
+ <div class="blog-post-layout">
47
+ <!-- Main Content Column -->
48
+ <div class="blog-content-col">
49
+ <div v-if="paginatedPosts.length" class="posts-grid">
50
+ <FeatureCard
51
+ v-for="post in paginatedPosts"
52
+ :key="post.url"
53
+ :title="post.title"
54
+ :content="post.excerpt"
55
+ :image="post.coverImage ? getCoverImageUrl(post.coverImage) : undefined"
56
+ :date="post.date"
57
+ :authors="post.authors"
58
+ :chips="post.tags ? post.tags.map(t => ({ text: t })) : []"
59
+ :link="{
60
+ text: 'Read more',
61
+ href: post.url,
62
+ target: '_self'
63
+ }"
64
+ variant="cover"
65
+ class="h-full bg-surface-container-low"
66
+ />
67
+ </div>
68
+
69
+ <!-- Empty State -->
70
+ <div
71
+ v-else
72
+ class="text-center py-20 bg-surface-container-low rounded-2xl border border-slate-100/50 max-w-xl mx-auto my-12"
73
+ >
74
+ <i class="mdi mdi-text-search text-5xl text-slate-300 mb-4 block"></i>
75
+ <h3 class="text-xl font-bold text-slate-900 mb-2">No posts found</h3>
76
+ <p class="text-slate-500 mb-6">
77
+ We couldn't find any articles matching your filters.
78
+ </p>
79
+ <button
80
+ @click="clearFilters"
81
+ class="px-5 py-2.5 text-sm font-semibold text-white bg-primary hover:bg-primary/90 rounded-xl transition-colors shadow-sm"
82
+ >
83
+ Clear Filters
84
+ </button>
85
+ </div>
86
+
87
+ <!-- Pagination -->
88
+ <nav
89
+ v-if="totalPages > 1"
90
+ class="mt-16 flex justify-center items-center gap-2"
91
+ >
92
+ <button
93
+ @click="changePage(currentPage - 1)"
94
+ :disabled="currentPage === 1"
95
+ class="p-2.5 rounded-xl border border-slate-200 text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-transparent disabled:cursor-not-allowed transition-colors"
96
+ >
97
+ <span class="sr-only">Previous</span>
98
+ <i class="mdi mdi-chevron-left text-lg"></i>
99
+ </button>
100
+
101
+ <div class="flex items-center gap-1.5">
102
+ <button
103
+ v-for="page in visiblePages"
104
+ :key="page"
105
+ @click="changePage(page)"
106
+ :class="[
107
+ currentPage === page
108
+ ? 'bg-primary text-white border-primary shadow-sm'
109
+ : 'border-slate-200 text-slate-600 hover:bg-slate-50',
110
+ ]"
111
+ class="min-w-[40px] h-[40px] px-3 font-semibold text-sm rounded-xl border flex items-center justify-center transition-all duration-150"
112
+ >
113
+ {{ page }}
114
+ </button>
115
+ </div>
116
+
117
+ <button
118
+ @click="changePage(currentPage + 1)"
119
+ :disabled="currentPage === totalPages"
120
+ class="p-2.5 rounded-xl border border-slate-200 text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-transparent disabled:cursor-not-allowed transition-colors"
121
+ >
122
+ <span class="sr-only">Next</span>
123
+ <i class="mdi mdi-chevron-right text-lg"></i>
124
+ </button>
125
+ </nav>
126
+ </div>
127
+
128
+ <!-- Sidebar Column -->
129
+ <aside class="blog-sidebar-col">
130
+ <!-- Search widget -->
131
+ <div class="sidebar-widget">
132
+ <h3 class="sidebar-title">
133
+ Search
134
+ </h3>
135
+ <div class="search-container">
136
+ <VPNavBarSearch class="search" />
137
+ </div>
138
+ </div>
139
+
140
+ <!-- Hot Tags -->
141
+ <div class="sidebar-widget">
142
+ <div class="sidebar-title-container">
143
+ <h3 class="sidebar-title-inline">
144
+ Hot tags
145
+ </h3>
146
+ <a href="/tag" class="see-all-link">
147
+ <span>see all</span>
148
+ <i class="mdi mdi-arrow-right"></i>
149
+ </a>
150
+ </div>
151
+ <div class="sidebar-tags-container">
152
+ <a
153
+ v-for="tag in calculatedHotTags"
154
+ :key="tag.name"
155
+ :href="`/tag/${tag.name}/`"
156
+ :style="getTagStyle(tag.name)"
157
+ class="sidebar-tag"
158
+ >
159
+ {{ tag.name }} ({{ tag.count }})
160
+ </a>
161
+ </div>
162
+ </div>
163
+ </aside>
164
+ </div>
165
+ </div>
166
+ </template>
167
+
168
+ <script setup>
169
+ import { ref, computed, onMounted, watch } from "vue";
170
+ import { useRouter, useData } from "vitepress";
171
+ import { data as posts } from "../posts.data.js";
172
+ import { data as authorsList } from "../authors.data.js";
173
+ import { getTagColor, getTagStyle } from "../tagUtils.js";
174
+ import { VPNavBarSearch } from "vitepress/theme";
175
+ import FeatureCard from "./FeatureCard/Index.vue";
176
+
177
+ // Dynamic Hot Tags
178
+ const calculatedHotTags = computed(() => {
179
+ const tagCounts = {};
180
+ posts.forEach((p) => {
181
+ if (Array.isArray(p.tags)) {
182
+ p.tags.forEach((t) => {
183
+ const clean = t.trim();
184
+ if (clean) {
185
+ tagCounts[clean] = (tagCounts[clean] || 0) + 1;
186
+ }
187
+ });
188
+ }
189
+ });
190
+ return Object.entries(tagCounts)
191
+ .map(([name, count]) => ({
192
+ name,
193
+ count,
194
+ color: getTagColor(name),
195
+ }))
196
+ .sort((a, b) => b.count - a.count)
197
+ .slice(0, 10);
198
+ });
199
+
200
+ const router = useRouter();
201
+ const { params } = useData();
202
+
203
+ const activeTag = ref("");
204
+ const activeAuthor = ref("");
205
+ const currentPage = ref(1);
206
+ const postsPerPage = 9;
207
+
208
+ // Form cover image URL correctly (prefixing filename with /images/)
209
+ function getCoverImageUrl(coverImage) {
210
+ if (!coverImage) return "";
211
+ if (coverImage.startsWith("/") || coverImage.startsWith("http")) {
212
+ return coverImage;
213
+ }
214
+ return `/images/${coverImage}`;
215
+ }
216
+
217
+ const tagFromParam = computed(() => {
218
+ return params.value?.tag || "";
219
+ });
220
+
221
+ const authorFromParam = computed(() => {
222
+ return params.value?.author || "";
223
+ });
224
+
225
+ // Reactively parse query filters on client side
226
+ function updateFilters() {
227
+ if (typeof window === "undefined") return;
228
+ const urlParams = new URLSearchParams(window.location.search);
229
+ activeTag.value = tagFromParam.value || urlParams.get("tag") || "";
230
+ activeAuthor.value = authorFromParam.value || urlParams.get("author") || "";
231
+ currentPage.value = 1; // Reset to page 1 on filter change
232
+ }
233
+
234
+ onMounted(() => {
235
+ updateFilters();
236
+ window.addEventListener("popstate", updateFilters);
237
+ });
238
+
239
+ // Watch router path/search changes
240
+ watch(() => router.route.path, updateFilters);
241
+ watch(tagFromParam, (newVal) => {
242
+ activeTag.value = newVal;
243
+ });
244
+ watch(authorFromParam, (newVal) => {
245
+ activeAuthor.value = newVal;
246
+ });
247
+ if (typeof window !== "undefined") {
248
+ watch(() => window.location.search, updateFilters);
249
+ }
250
+
251
+ // Filter posts
252
+ const filteredPosts = computed(() => {
253
+ return posts.filter((post) => {
254
+ // Filter by tag
255
+ const tagToFilter = activeTag.value || tagFromParam.value;
256
+ if (tagToFilter) {
257
+ const matchTag = post.tags.some(
258
+ (t) => t.toLowerCase().trim() === tagToFilter.toLowerCase().trim(),
259
+ );
260
+ if (!matchTag) return false;
261
+ }
262
+ // Filter by author
263
+ const authorToFilter = activeAuthor.value || authorFromParam.value;
264
+ if (authorToFilter) {
265
+ const matchAuthor = post.authors.some(
266
+ (a) => a.toLowerCase().trim() === authorToFilter.toLowerCase().trim(),
267
+ );
268
+ if (!matchAuthor) return false;
269
+ }
270
+ return true;
271
+ });
272
+ });
273
+
274
+ // Paginated posts
275
+ const paginatedPosts = computed(() => {
276
+ const startIndex = (currentPage.value - 1) * postsPerPage;
277
+ return filteredPosts.value.slice(startIndex, startIndex + postsPerPage);
278
+ });
279
+
280
+ // Total pages
281
+ const totalPages = computed(() => {
282
+ return Math.ceil(filteredPosts.value.length / postsPerPage);
283
+ });
284
+
285
+ // Page navigation helper
286
+ function changePage(page) {
287
+ if (page < 1 || page > totalPages.value) return;
288
+ currentPage.value = page;
289
+ if (typeof window !== "undefined") {
290
+ window.scrollTo({ top: 0, behavior: "smooth" });
291
+ }
292
+ }
293
+
294
+ // Generate list of page numbers to render
295
+ const visiblePages = computed(() => {
296
+ const pages = [];
297
+ const maxPages = 5;
298
+ let start = Math.max(1, currentPage.value - Math.floor(maxPages / 2));
299
+ let end = Math.min(totalPages.value, start + maxPages - 1);
300
+ if (end - start + 1 < maxPages) {
301
+ start = Math.max(1, end - maxPages + 1);
302
+ }
303
+ for (let i = start; i <= end; i++) {
304
+ pages.push(i);
305
+ }
306
+ return pages;
307
+ });
308
+
309
+ // Format publication date
310
+ function formatDate(dateStr) {
311
+ if (!dateStr) return "";
312
+ const date = new Date(dateStr);
313
+ return date.toLocaleDateString("en-US", {
314
+ year: "numeric",
315
+ month: "long",
316
+ day: "numeric",
317
+ });
318
+ }
319
+
320
+ // Get author name
321
+ function getAuthorName(authorId) {
322
+ const auth = authorsList.find((a) => a.id === authorId);
323
+ return auth ? auth.name : authorId;
324
+ }
325
+
326
+ function clearFilters() {
327
+ router.go("/blog");
328
+ }
329
+ </script>
330
+
331
+ <style scoped>
332
+ .blog-landing {
333
+ width: 100%;
334
+ max-width: 1200px !important;
335
+ margin-left: auto !important;
336
+ margin-right: auto !important;
337
+ padding-left: 1rem !important;
338
+ padding-right: 1rem !important;
339
+ box-sizing: border-box !important;
340
+ padding-top: calc(var(--vp-nav-height) + 28px) !important;
341
+ padding-bottom: 48px !important;
342
+ }
343
+
344
+ @media (min-width: 640px) {
345
+ .blog-landing {
346
+ padding-left: 1.5rem !important;
347
+ padding-right: 1.5rem !important;
348
+ padding-top: calc(var(--vp-nav-height) + 40px) !important;
349
+ padding-bottom: 64px !important;
350
+ }
351
+ }
352
+
353
+ @media (min-width: 960px) {
354
+ .blog-landing {
355
+ padding-left: 2rem !important;
356
+ padding-right: 2rem !important;
357
+ padding-top: calc(var(--vp-nav-height) + 58px) !important;
358
+ padding-bottom: 96px !important;
359
+ }
360
+ }
361
+
362
+ .blog-landing-header {
363
+ margin-bottom: 2rem;
364
+ border-bottom: 1px solid #f1f5f9; /* slate-100 */
365
+ padding-bottom: 1.5rem !important;
366
+ padding-top: 0 !important;
367
+ padding-left: 0 !important;
368
+ padding-right: 0 !important;
369
+ background: transparent !important;
370
+ min-height: auto !important;
371
+ height: auto !important;
372
+ }
373
+
374
+ .blog-landing-title {
375
+ font-family: "Inter", sans-serif;
376
+ font-style: normal;
377
+ font-weight: 700;
378
+ font-size: 28px;
379
+ line-height: 36px;
380
+ letter-spacing: 0.5px;
381
+ color: #020617; /* slate-950 */
382
+ margin-top: 0;
383
+ margin-bottom: 0.5rem;
384
+ }
385
+
386
+ @media (min-width: 768px) {
387
+ .blog-landing-title {
388
+ font-size: 32px;
389
+ line-height: 40px;
390
+ }
391
+ }
392
+
393
+ .blog-landing-subtitle {
394
+ font-family: "Inter", sans-serif;
395
+ font-style: normal;
396
+ font-weight: 400;
397
+ font-size: 16px;
398
+ line-height: 24px;
399
+ letter-spacing: 0.5px;
400
+ color: #64748b; /* slate-500 */
401
+ max-width: 620px;
402
+ margin: 0;
403
+ padding: 0;
404
+ }
405
+
406
+ .posts-grid {
407
+ display: flex;
408
+ flex-direction: column;
409
+ gap: 2.5rem;
410
+ }
411
+
412
+ :deep(.card-cover-link) {
413
+ max-height: 280px !important;
414
+ }
415
+
416
+ .blog-post-layout {
417
+ display: grid;
418
+ grid-template-columns: 1fr;
419
+ gap: 2.5rem;
420
+ }
421
+
422
+ .blog-content-col {
423
+ width: 100%;
424
+ min-width: 0;
425
+ }
426
+
427
+ .blog-sidebar-col {
428
+ width: 100%;
429
+ display: flex;
430
+ flex-direction: column;
431
+ gap: 2.5rem;
432
+ }
433
+
434
+ @media (min-width: 1024px) {
435
+ .blog-post-layout {
436
+ grid-template-columns: minmax(0, 1fr) 280px;
437
+ gap: 4rem;
438
+ align-items: start;
439
+ }
440
+ .blog-sidebar-col {
441
+ position: sticky !important;
442
+ align-self: start !important;
443
+ top: calc(var(--vp-nav-height) + 2rem) !important;
444
+ max-height: calc(100vh - var(--vp-nav-height) - 4rem);
445
+ overflow-y: auto;
446
+ scrollbar-width: none; /* Hide scrollbar for cleaner look */
447
+ }
448
+ .blog-sidebar-col::-webkit-scrollbar {
449
+ display: none;
450
+ }
451
+ }
452
+
453
+ .sidebar-widget {
454
+ display: flex;
455
+ flex-direction: column;
456
+ gap: 0.75rem;
457
+ }
458
+
459
+ .sidebar-title {
460
+ font-size: 0.75rem;
461
+ font-weight: 600;
462
+ text-transform: uppercase;
463
+ letter-spacing: 0.05em;
464
+ color: #64748b; /* slate-500 */
465
+ border-bottom: 1px solid #f1f5f9; /* slate-100 */
466
+ padding-bottom: 0.5rem;
467
+ margin: 0;
468
+ }
469
+
470
+ .sidebar-title-container {
471
+ display: flex;
472
+ align-items: center;
473
+ justify-content: space-between;
474
+ border-bottom: 1px solid #f1f5f9; /* slate-100 */
475
+ padding-bottom: 0.5rem;
476
+ margin: 0;
477
+ }
478
+
479
+ .sidebar-title-inline {
480
+ font-size: 0.75rem;
481
+ font-weight: 600;
482
+ text-transform: uppercase;
483
+ letter-spacing: 0.05em;
484
+ color: #64748b; /* slate-500 */
485
+ margin: 0;
486
+ padding: 0;
487
+ }
488
+
489
+ .see-all-link {
490
+ font-size: 0.75rem;
491
+ font-weight: 600;
492
+ color: var(--vp-c-brand, #004170);
493
+ text-decoration: none;
494
+ display: inline-flex;
495
+ align-items: center;
496
+ gap: 0.25rem;
497
+ transition: opacity 0.2s;
498
+ }
499
+
500
+ .see-all-link:hover {
501
+ text-decoration: underline;
502
+ opacity: 0.8;
503
+ }
504
+
505
+ .see-all-link i {
506
+ font-size: 0.75rem;
507
+ }
508
+
509
+ .sidebar-tags-container {
510
+ display: flex !important;
511
+ flex-wrap: wrap !important;
512
+ gap: 0.5rem !important;
513
+ margin-top: 0.25rem !important;
514
+ }
515
+
516
+ .sidebar-tag {
517
+ display: inline-flex !important;
518
+ align-items: center !important;
519
+ font-size: 0.75rem !important;
520
+ font-weight: 600 !important;
521
+ padding: 0.25rem 0.75rem !important;
522
+ border-radius: 4px !important;
523
+ text-decoration: none !important;
524
+ line-height: 1.2 !important;
525
+ transition: opacity 0.2s ease, transform 0.1s ease !important;
526
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05) !important;
527
+ }
528
+
529
+ /* Active Filter Banner Styles */
530
+ .active-filter-banner {
531
+ display: flex;
532
+ flex-wrap: wrap;
533
+ align-items: center;
534
+ gap: 0.75rem;
535
+ background-color: var(--surface-container-low, #f8fafc);
536
+ padding: 0.75rem 1.25rem !important;
537
+ border-radius: 0.75rem;
538
+ border: 1px solid rgba(226, 232, 240, 0.8); /* slate-200/80 */
539
+ margin-top: 1.5rem;
540
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
541
+ }
542
+
543
+ .filter-label {
544
+ font-size: 0.875rem; /* text-sm */
545
+ color: #64748b; /* slate-500 */
546
+ font-weight: 500;
547
+ }
548
+
549
+ .active-filter-tag {
550
+ display: inline-flex !important;
551
+ align-items: center !important;
552
+ font-size: 0.75rem !important;
553
+ font-weight: 700 !important;
554
+ padding: 0.25rem 0.75rem !important;
555
+ border-radius: 4px !important;
556
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
557
+ line-height: 1.2 !important;
558
+ }
559
+
560
+ .active-filter-author {
561
+ display: inline-flex !important;
562
+ align-items: center !important;
563
+ font-size: 0.75rem !important;
564
+ font-weight: 700 !important;
565
+ padding: 0.25rem 0.75rem !important;
566
+ border-radius: 4px !important;
567
+ background-color: var(--primary-container) !important;
568
+ color: var(--primary) !important;
569
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
570
+ line-height: 1.2 !important;
571
+ }
572
+
573
+ /* Clear Filter Button Reset & Style */
574
+ .clear-filter-btn {
575
+ /* Complete reset of standard button styling */
576
+ background: none !important;
577
+ background-color: transparent !important;
578
+ border: none !important;
579
+ outline: none !important;
580
+ box-shadow: none !important;
581
+ height: auto !important;
582
+ min-height: auto !important;
583
+ width: auto !important;
584
+ padding: 0 !important;
585
+ margin: 0 !important;
586
+ margin-left: 0.5rem !important;
587
+
588
+ display: inline-flex !important;
589
+ align-items: center !important;
590
+ gap: 0.375rem !important;
591
+ font-size: 0.8125rem !important; /* 13px */
592
+ font-weight: 600 !important;
593
+ color: #94a3b8 !important; /* slate-400 */
594
+ cursor: pointer !important;
595
+ transition: color 0.15s ease !important;
596
+ }
597
+
598
+ .clear-filter-btn:hover {
599
+ color: #475569 !important; /* slate-600 */
600
+ background: none !important;
601
+ background-color: transparent !important;
602
+ }
603
+
604
+ .clear-filter-btn i {
605
+ font-size: 1rem !important;
606
+ color: inherit !important;
607
+ vertical-align: middle !important;
608
+ }
609
+
610
+ .sidebar-tag:hover {
611
+ opacity: 0.8 !important;
612
+ transform: translateY(-1px) !important;
613
+ }
614
+
615
+ .search-container {
616
+ margin-top: 0.75rem;
617
+ margin-bottom: 0.5rem;
618
+ }
619
+
620
+ :deep(.VPNavBarSearch) {
621
+ padding-left: 0 !important;
622
+ }
623
+
624
+ .line-clamp-2 {
625
+ display: -webkit-box;
626
+ -webkit-line-clamp: 2;
627
+ -webkit-box-orient: vertical;
628
+ overflow: hidden;
629
+ }
630
+ .line-clamp-3 {
631
+ display: -webkit-box;
632
+ -webkit-line-clamp: 3;
633
+ -webkit-box-orient: vertical;
634
+ overflow: hidden;
635
+ }
636
+ </style>