|
68 | 68 | </div>
|
69 | 69 | </div>
|
70 | 70 |
|
| 71 | + <!-- Growth Chart --> |
| 72 | + <div class="mb-8 px-4 lg:px-8 sm:px-6"> |
| 73 | + <div class="bg-white dark:bg-blue-gray-700 rounded-lg shadow"> |
| 74 | + <div class="p-6"> |
| 75 | + <div class="flex items-center justify-between mb-6"> |
| 76 | + <div> |
| 77 | + <h3 class="text-base font-medium text-gray-900 dark:text-gray-100">Subscriber Growth</h3> |
| 78 | + <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Track subscriber count over time</p> |
| 79 | + </div> |
| 80 | + <div class="flex items-center space-x-4"> |
| 81 | + <select |
| 82 | + v-model="timeRange" |
| 83 | + class="h-9 text-sm border-0 rounded-md bg-gray-50 dark:bg-blue-gray-600 py-1.5 pl-3 pr-8 text-gray-900 dark:text-gray-100 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 focus:ring-2 focus:ring-blue-600" |
| 84 | + > |
| 85 | + <option value="7">Last 7 days</option> |
| 86 | + <option value="30">Last 30 days</option> |
| 87 | + <option value="90">Last 90 days</option> |
| 88 | + <option value="365">Last year</option> |
| 89 | + </select> |
| 90 | + </div> |
| 91 | + </div> |
| 92 | + <div class="h-[400px] relative"> |
| 93 | + <div v-if="isLoading" class="absolute inset-0 flex items-center justify-center bg-white bg-opacity-75 dark:bg-blue-gray-700 dark:bg-opacity-75 z-10"> |
| 94 | + <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div> |
| 95 | + </div> |
| 96 | + <Line :data="chartData" :options="chartOptions" /> |
| 97 | + </div> |
| 98 | + </div> |
| 99 | + </div> |
| 100 | + </div> |
| 101 | + |
71 | 102 | <div class="px-4 pt-12 lg:px-8 sm:px-6">
|
72 | 103 | <div class="sm:flex sm:items-center">
|
73 | 104 | <div class="sm:flex-auto">
|
|
198 | 229 | </div>
|
199 | 230 | </div>
|
200 | 231 | </template>
|
| 232 | + |
| 233 | +<script lang="ts" setup> |
| 234 | +import { ref, computed, watch, onMounted } from 'vue' |
| 235 | +import { useHead } from '@vueuse/head' |
| 236 | +import { Line } from 'vue-chartjs' |
| 237 | +import { |
| 238 | + Chart as ChartJS, |
| 239 | + CategoryScale, |
| 240 | + LinearScale, |
| 241 | + PointElement, |
| 242 | + LineElement, |
| 243 | + Title, |
| 244 | + Tooltip, |
| 245 | + Legend, |
| 246 | + Filler, |
| 247 | + Scale, |
| 248 | + CoreScaleOptions, |
| 249 | +} from 'chart.js' |
| 250 | +
|
| 251 | +ChartJS.register( |
| 252 | + CategoryScale, |
| 253 | + LinearScale, |
| 254 | + PointElement, |
| 255 | + LineElement, |
| 256 | + Title, |
| 257 | + Tooltip, |
| 258 | + Legend, |
| 259 | + Filler, |
| 260 | +) |
| 261 | +
|
| 262 | +useHead({ |
| 263 | + title: 'Dashboard - Subscribers', |
| 264 | +}) |
| 265 | +
|
| 266 | +const timeRange = ref<'7' | '30' | '90' | '365'>('30') |
| 267 | +const isLoading = ref(false) |
| 268 | +
|
| 269 | +// Helper function to format dates |
| 270 | +const formatDate = (daysAgo: number) => { |
| 271 | + const date = new Date() |
| 272 | + date.setDate(date.getDate() - daysAgo) |
| 273 | + return date.toLocaleDateString('en-US', { |
| 274 | + month: 'short', |
| 275 | + day: 'numeric' |
| 276 | + }) |
| 277 | +} |
| 278 | +
|
| 279 | +// Generate date labels for the selected time range |
| 280 | +const generateDateLabels = (days: number) => { |
| 281 | + return Array.from({ length: days }, (_, i) => formatDate(days - 1 - i)).reverse() |
| 282 | +} |
| 283 | +
|
| 284 | +// Generate mock growth data |
| 285 | +const generateGrowthData = (days: number, baseCount: number, dailyGrowth: number) => { |
| 286 | + return Array.from({ length: days }, (_, i) => { |
| 287 | + const dayVariance = Math.random() * dailyGrowth * 0.5 // 50% variance |
| 288 | + return Math.floor(baseCount + (dailyGrowth * i) + dayVariance) |
| 289 | + }) |
| 290 | +} |
| 291 | +
|
| 292 | +// Chart options |
| 293 | +const chartOptions = { |
| 294 | + responsive: true, |
| 295 | + maintainAspectRatio: false, |
| 296 | + scales: { |
| 297 | + y: { |
| 298 | + type: 'linear' as const, |
| 299 | + beginAtZero: true, |
| 300 | + grid: { |
| 301 | + color: 'rgba(200, 200, 200, 0.1)', |
| 302 | + }, |
| 303 | + ticks: { |
| 304 | + color: 'rgb(156, 163, 175)', |
| 305 | + font: { |
| 306 | + family: "'JetBrains Mono', monospace", |
| 307 | + }, |
| 308 | + callback: function(this: Scale<CoreScaleOptions>, tickValue: string | number) { |
| 309 | + const value = Number(tickValue) |
| 310 | + if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M` |
| 311 | + if (value >= 1000) return `${(value / 1000).toFixed(1)}k` |
| 312 | + return value.toString() |
| 313 | + } |
| 314 | + }, |
| 315 | + }, |
| 316 | + x: { |
| 317 | + type: 'category' as const, |
| 318 | + grid: { |
| 319 | + display: false, |
| 320 | + }, |
| 321 | + ticks: { |
| 322 | + color: 'rgb(156, 163, 175)', |
| 323 | + font: { |
| 324 | + family: "'JetBrains Mono', monospace", |
| 325 | + }, |
| 326 | + }, |
| 327 | + }, |
| 328 | + }, |
| 329 | + plugins: { |
| 330 | + legend: { |
| 331 | + display: true, |
| 332 | + position: 'top' as const, |
| 333 | + align: 'end' as const, |
| 334 | + labels: { |
| 335 | + color: 'rgb(156, 163, 175)', |
| 336 | + font: { |
| 337 | + family: "'JetBrains Mono', monospace", |
| 338 | + }, |
| 339 | + boxWidth: 12, |
| 340 | + padding: 15, |
| 341 | + }, |
| 342 | + }, |
| 343 | + tooltip: { |
| 344 | + mode: 'index' as const, |
| 345 | + intersect: false, |
| 346 | + callbacks: { |
| 347 | + label: (context: any) => { |
| 348 | + let label = context.dataset.label || '' |
| 349 | + if (label) label += ': ' |
| 350 | + if (context.parsed.y !== null) { |
| 351 | + const value = context.parsed.y |
| 352 | + if (value >= 1000000) return `${label}${(value / 1000000).toFixed(1)}M subscribers` |
| 353 | + if (value >= 1000) return `${label}${(value / 1000).toFixed(1)}k subscribers` |
| 354 | + return `${label}${value} subscribers` |
| 355 | + } |
| 356 | + return label |
| 357 | + } |
| 358 | + } |
| 359 | + } |
| 360 | + }, |
| 361 | + interaction: { |
| 362 | + mode: 'index' as const, |
| 363 | + intersect: false, |
| 364 | + }, |
| 365 | +} as const |
| 366 | +
|
| 367 | +// Chart data |
| 368 | +const chartData = computed(() => { |
| 369 | + const days = parseInt(timeRange.value) |
| 370 | + const labels = generateDateLabels(days) |
| 371 | + const baseCount = 500 // Starting with 500 subscribers |
| 372 | + const dailyGrowth = 5 // Average 5 new subscribers per day |
| 373 | +
|
| 374 | + return { |
| 375 | + labels, |
| 376 | + datasets: [{ |
| 377 | + label: 'Total Subscribers', |
| 378 | + data: generateGrowthData(days, baseCount, dailyGrowth), |
| 379 | + borderColor: 'rgb(14, 165, 233)', // Sky blue color for subscribers |
| 380 | + backgroundColor: 'rgba(14, 165, 233, 0.1)', |
| 381 | + fill: true, |
| 382 | + tension: 0.4 |
| 383 | + }] |
| 384 | + } |
| 385 | +}) |
| 386 | +
|
| 387 | +// Watch for time range changes |
| 388 | +watch(timeRange, async () => { |
| 389 | + isLoading.value = true |
| 390 | + // Simulate API delay |
| 391 | + await new Promise(resolve => setTimeout(resolve, 500)) |
| 392 | + isLoading.value = false |
| 393 | +}) |
| 394 | +
|
| 395 | +// Initial load |
| 396 | +onMounted(async () => { |
| 397 | + isLoading.value = true |
| 398 | + await new Promise(resolve => setTimeout(resolve, 500)) |
| 399 | + isLoading.value = false |
| 400 | +}) |
| 401 | +</script> |
0 commit comments