|
1 | 1 | <script lang="ts" setup>
|
2 | 2 | import { ref, computed } from 'vue'
|
3 | 3 | import { useHead } from '@vueuse/head'
|
| 4 | +import { Line, Bar, Doughnut } from 'vue-chartjs' |
| 5 | +import { |
| 6 | + Chart as ChartJS, |
| 7 | + CategoryScale, |
| 8 | + LinearScale, |
| 9 | + PointElement, |
| 10 | + LineElement, |
| 11 | + BarElement, |
| 12 | + ArcElement, |
| 13 | + Title, |
| 14 | + Tooltip, |
| 15 | + Legend, |
| 16 | + Filler, |
| 17 | +} from 'chart.js' |
| 18 | +
|
| 19 | +ChartJS.register( |
| 20 | + CategoryScale, |
| 21 | + LinearScale, |
| 22 | + PointElement, |
| 23 | + LineElement, |
| 24 | + BarElement, |
| 25 | + ArcElement, |
| 26 | + Title, |
| 27 | + Tooltip, |
| 28 | + Legend, |
| 29 | + Filler, |
| 30 | +) |
4 | 31 |
|
5 | 32 | useHead({
|
6 | 33 | title: 'Dashboard - Blog Categories',
|
7 | 34 | })
|
8 | 35 |
|
| 36 | +// Chart options |
| 37 | +const chartOptions = { |
| 38 | + responsive: true, |
| 39 | + maintainAspectRatio: false, |
| 40 | + plugins: { |
| 41 | + legend: { |
| 42 | + display: false, |
| 43 | + }, |
| 44 | + }, |
| 45 | + scales: { |
| 46 | + y: { |
| 47 | + beginAtZero: true, |
| 48 | + grid: { |
| 49 | + display: true, |
| 50 | + color: 'rgba(0, 0, 0, 0.05)', |
| 51 | + }, |
| 52 | + }, |
| 53 | + x: { |
| 54 | + grid: { |
| 55 | + display: false, |
| 56 | + }, |
| 57 | + }, |
| 58 | + }, |
| 59 | +} |
| 60 | +
|
| 61 | +// Doughnut chart options (no scales) |
| 62 | +const doughnutChartOptions = { |
| 63 | + responsive: true, |
| 64 | + maintainAspectRatio: false, |
| 65 | + plugins: { |
| 66 | + legend: { |
| 67 | + display: true, |
| 68 | + position: 'bottom' as const, |
| 69 | + }, |
| 70 | + }, |
| 71 | +} |
| 72 | +
|
| 73 | +// Generate monthly data for charts |
| 74 | +const monthlyChartData = computed(() => { |
| 75 | + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] |
| 76 | +
|
| 77 | + // Sample data - in a real app, this would be calculated from actual category data |
| 78 | + const categoryGrowthData = [5, 6, 7, 8, 8, 9, 10, 10, 11, 11, 12, 12] |
| 79 | +
|
| 80 | + // Category growth chart data |
| 81 | + const categoryGrowthChartData = { |
| 82 | + labels: months, |
| 83 | + datasets: [ |
| 84 | + { |
| 85 | + label: 'Categories', |
| 86 | + backgroundColor: 'rgba(59, 130, 246, 0.2)', |
| 87 | + borderColor: 'rgba(59, 130, 246, 1)', |
| 88 | + borderWidth: 2, |
| 89 | + pointBackgroundColor: 'rgba(59, 130, 246, 1)', |
| 90 | + pointBorderColor: '#fff', |
| 91 | + pointHoverBackgroundColor: '#fff', |
| 92 | + pointHoverBorderColor: 'rgba(59, 130, 246, 1)', |
| 93 | + fill: true, |
| 94 | + tension: 0.4, |
| 95 | + data: categoryGrowthData, |
| 96 | + }, |
| 97 | + ], |
| 98 | + } |
| 99 | +
|
| 100 | + // Posts per category chart data |
| 101 | + const postCountData = categories.value.map(category => category.postCount) |
| 102 | + const categoryNames = categories.value.map(category => category.name) |
| 103 | +
|
| 104 | + const postsPerCategoryChartData = { |
| 105 | + labels: categoryNames, |
| 106 | + datasets: [ |
| 107 | + { |
| 108 | + label: 'Posts', |
| 109 | + backgroundColor: 'rgba(16, 185, 129, 0.8)', |
| 110 | + borderColor: 'rgba(16, 185, 129, 1)', |
| 111 | + borderWidth: 1, |
| 112 | + borderRadius: 4, |
| 113 | + data: postCountData, |
| 114 | + }, |
| 115 | + ], |
| 116 | + } |
| 117 | +
|
| 118 | + // Category distribution chart data |
| 119 | + const backgroundColors = [ |
| 120 | + 'rgba(59, 130, 246, 0.8)', |
| 121 | + 'rgba(16, 185, 129, 0.8)', |
| 122 | + 'rgba(245, 158, 11, 0.8)', |
| 123 | + 'rgba(239, 68, 68, 0.8)', |
| 124 | + 'rgba(139, 92, 246, 0.8)', |
| 125 | + 'rgba(236, 72, 153, 0.8)', |
| 126 | + 'rgba(75, 85, 99, 0.8)', |
| 127 | + 'rgba(14, 165, 233, 0.8)', |
| 128 | + 'rgba(168, 85, 247, 0.8)', |
| 129 | + 'rgba(249, 115, 22, 0.8)', |
| 130 | + 'rgba(234, 88, 12, 0.8)', |
| 131 | + 'rgba(217, 119, 6, 0.8)', |
| 132 | + ] |
| 133 | +
|
| 134 | + const categoryDistributionChartData = { |
| 135 | + labels: categoryNames, |
| 136 | + datasets: [ |
| 137 | + { |
| 138 | + data: postCountData, |
| 139 | + backgroundColor: backgroundColors.slice(0, categoryNames.length), |
| 140 | + borderWidth: 0, |
| 141 | + }, |
| 142 | + ], |
| 143 | + } |
| 144 | +
|
| 145 | + return { |
| 146 | + categoryGrowthChartData, |
| 147 | + postsPerCategoryChartData, |
| 148 | + categoryDistributionChartData, |
| 149 | + } |
| 150 | +}) |
| 151 | +
|
| 152 | +// Time range selector |
| 153 | +const timeRange = ref('Last 30 days') |
| 154 | +const timeRanges = ['Today', 'Last 7 days', 'Last 30 days', 'Last 90 days', 'Last year', 'All time'] |
| 155 | +
|
9 | 156 | // Define category type
|
10 | 157 | interface Category {
|
11 | 158 | id: number
|
@@ -204,6 +351,62 @@ const paginationRange = computed(() => {
|
204 | 351 | return range
|
205 | 352 | })
|
206 | 353 |
|
| 354 | +// Computed category statistics |
| 355 | +const categoryStats = computed(() => { |
| 356 | + // Total number of categories |
| 357 | + const totalCategories = categories.value.length |
| 358 | +
|
| 359 | + // Total number of posts across all categories |
| 360 | + const totalPosts = categories.value.reduce((sum, category) => sum + category.postCount, 0) |
| 361 | +
|
| 362 | + // Average posts per category |
| 363 | + const avgPostsPerCategory = totalCategories > 0 |
| 364 | + ? (totalPosts / totalCategories).toFixed(1) |
| 365 | + : '0.0' |
| 366 | +
|
| 367 | + // Find category with most posts |
| 368 | + let mostPopularCategory = categories.value[0] || { name: 'None', postCount: 0 } |
| 369 | +
|
| 370 | + for (const category of categories.value) { |
| 371 | + if (category.postCount > mostPopularCategory.postCount) { |
| 372 | + mostPopularCategory = category |
| 373 | + } |
| 374 | + } |
| 375 | +
|
| 376 | + // Find category with least posts |
| 377 | + let leastPopularCategory = categories.value[0] || { name: 'None', postCount: 0 } |
| 378 | +
|
| 379 | + for (const category of categories.value) { |
| 380 | + if (category.postCount < leastPopularCategory.postCount) { |
| 381 | + leastPopularCategory = category |
| 382 | + } |
| 383 | + } |
| 384 | +
|
| 385 | + // Calculate percentage of posts in top category |
| 386 | + const topCategoryPercentage = totalPosts > 0 |
| 387 | + ? ((mostPopularCategory.postCount / totalPosts) * 100).toFixed(1) |
| 388 | + : '0.0' |
| 389 | +
|
| 390 | + // Find newest category |
| 391 | + let newestCategory = categories.value[0] || { name: 'None', createdAt: '' } as Category |
| 392 | +
|
| 393 | + for (const category of categories.value) { |
| 394 | + if (new Date(category.createdAt) > new Date(newestCategory.createdAt)) { |
| 395 | + newestCategory = category |
| 396 | + } |
| 397 | + } |
| 398 | +
|
| 399 | + return { |
| 400 | + totalCategories, |
| 401 | + totalPosts, |
| 402 | + avgPostsPerCategory, |
| 403 | + mostPopularCategory, |
| 404 | + leastPopularCategory, |
| 405 | + topCategoryPercentage, |
| 406 | + newestCategory |
| 407 | + } |
| 408 | +}) |
| 409 | +
|
207 | 410 | // Methods
|
208 | 411 | function openNewCategoryModal(): void {
|
209 | 412 | newCategory.value = {
|
@@ -353,8 +556,87 @@ const hasSelectedCategories = computed(() => selectedCategoryIds.value.length >
|
353 | 556 | </div>
|
354 | 557 | </div>
|
355 | 558 |
|
| 559 | + <!-- Time range selector --> |
| 560 | + <div class="mt-4 flex items-center justify-between"> |
| 561 | + <p class="text-sm text-gray-500 dark:text-gray-400"> |
| 562 | + Overview of your blog categories |
| 563 | + </p> |
| 564 | + <div class="relative"> |
| 565 | + <select v-model="timeRange" class="block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6 dark:bg-blue-gray-800 dark:text-white dark:ring-gray-700"> |
| 566 | + <option v-for="range in timeRanges" :key="range" :value="range">{{ range }}</option> |
| 567 | + </select> |
| 568 | + </div> |
| 569 | + </div> |
| 570 | + |
| 571 | + <!-- Stats --> |
| 572 | + <dl class="mt-5 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4"> |
| 573 | + <div class="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6 dark:bg-blue-gray-800"> |
| 574 | + <dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-300">Total Categories</dt> |
| 575 | + <dd class="mt-1 text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">{{ categoryStats.totalCategories }}</dd> |
| 576 | + <dd class="mt-2 flex items-center text-sm text-green-600 dark:text-green-400"> |
| 577 | + <div class="i-hugeicons-analytics-up h-4 w-4 mr-1"></div> |
| 578 | + <span>{{ categoryStats.newestCategory.name }} added recently</span> |
| 579 | + </dd> |
| 580 | + </div> |
| 581 | + |
| 582 | + <div class="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6 dark:bg-blue-gray-800"> |
| 583 | + <dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-300">Total Posts</dt> |
| 584 | + <dd class="mt-1 text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">{{ categoryStats.totalPosts }}</dd> |
| 585 | + <dd class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400"> |
| 586 | + <span>{{ categoryStats.avgPostsPerCategory }} avg per category</span> |
| 587 | + </dd> |
| 588 | + </div> |
| 589 | + |
| 590 | + <div class="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6 dark:bg-blue-gray-800"> |
| 591 | + <dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-300">Top Category</dt> |
| 592 | + <dd class="mt-1 text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">{{ categoryStats.mostPopularCategory.name }}</dd> |
| 593 | + <dd class="mt-2 flex items-center text-sm text-green-600 dark:text-green-400"> |
| 594 | + <div class="i-hugeicons-analytics-up h-4 w-4 mr-1"></div> |
| 595 | + <span>{{ categoryStats.mostPopularCategory.postCount }} posts ({{ categoryStats.topCategoryPercentage }}%)</span> |
| 596 | + </dd> |
| 597 | + </div> |
| 598 | + |
| 599 | + <div class="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6 dark:bg-blue-gray-800"> |
| 600 | + <dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-300">Least Used Category</dt> |
| 601 | + <dd class="mt-1 text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">{{ categoryStats.leastPopularCategory.name }}</dd> |
| 602 | + <dd class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400"> |
| 603 | + <span>{{ categoryStats.leastPopularCategory.postCount }} posts</span> |
| 604 | + </dd> |
| 605 | + </div> |
| 606 | + </dl> |
| 607 | + |
| 608 | + <!-- Charts --> |
| 609 | + <div class="mt-8 grid grid-cols-1 gap-5 lg:grid-cols-3"> |
| 610 | + <div class="overflow-hidden rounded-lg bg-white shadow dark:bg-blue-gray-800"> |
| 611 | + <div class="p-6"> |
| 612 | + <h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">Category Growth</h3> |
| 613 | + <div class="mt-2 h-80"> |
| 614 | + <Line :data="monthlyChartData.categoryGrowthChartData" :options="chartOptions" /> |
| 615 | + </div> |
| 616 | + </div> |
| 617 | + </div> |
| 618 | + |
| 619 | + <div class="overflow-hidden rounded-lg bg-white shadow dark:bg-blue-gray-800"> |
| 620 | + <div class="p-6"> |
| 621 | + <h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">Posts per Category</h3> |
| 622 | + <div class="mt-2 h-80"> |
| 623 | + <Bar :data="monthlyChartData.postsPerCategoryChartData" :options="chartOptions" /> |
| 624 | + </div> |
| 625 | + </div> |
| 626 | + </div> |
| 627 | + |
| 628 | + <div class="overflow-hidden rounded-lg bg-white shadow dark:bg-blue-gray-800"> |
| 629 | + <div class="p-6"> |
| 630 | + <h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">Category Distribution</h3> |
| 631 | + <div class="mt-2 h-80"> |
| 632 | + <Doughnut :data="monthlyChartData.categoryDistributionChartData" :options="doughnutChartOptions" /> |
| 633 | + </div> |
| 634 | + </div> |
| 635 | + </div> |
| 636 | + </div> |
| 637 | + |
356 | 638 | <!-- Filters -->
|
357 |
| - <div class="mt-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> |
| 639 | + <div class="mt-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> |
358 | 640 | <div class="relative max-w-sm">
|
359 | 641 | <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
360 | 642 | <div class="i-hugeicons-search-01 h-5 w-5 text-gray-400"></div>
|
@@ -418,6 +700,12 @@ const hasSelectedCategories = computed(() => selectedCategoryIds.value.length >
|
418 | 700 |
|
419 | 701 | <!-- Categories Table -->
|
420 | 702 | <div class="mt-6 flow-root">
|
| 703 | + <div class="sm:flex sm:items-center sm:justify-between mb-4"> |
| 704 | + <h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">All Categories</h3> |
| 705 | + <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| 706 | + A list of all blog categories including name, description, and post count. |
| 707 | + </p> |
| 708 | + </div> |
421 | 709 | <div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700 shadow">
|
422 | 710 | <div class="overflow-hidden">
|
423 | 711 | <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
0 commit comments