Files
2026-07-25 14:18:50 +02:00

313 lines
9.0 KiB
JavaScript

import Chart from 'chart.js/auto';
/**
* Theme-aware chart defaults
*/
function getChartColors() {
const isDark = localStorage.theme !== 'light' &&
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) {
return {
textColor: '#ADBABD',
gridColor: 'rgba(255, 255, 255, 0.06)',
fillStart: 'rgba(190, 18, 60, 0.2)',
fillEnd: 'rgba(190, 18, 60, 0.0)',
borderColor: 'rgba(190, 18, 60, 0.9)',
pointColor: 'rgba(190, 18, 60, 1)',
pointHoverColor: '#ffffff',
skeletonBase: '#262626',
skeletonShimmer: '#333333',
};
}
return {
textColor: '#6B7280',
gridColor: 'rgba(0, 0, 0, 0.06)',
fillStart: 'rgba(190, 18, 60, 0.15)',
fillEnd: 'rgba(190, 18, 60, 0.0)',
borderColor: 'rgba(190, 18, 60, 1.0)',
pointColor: 'rgba(190, 18, 60, 1)',
pointHoverColor: '#ffffff',
skeletonBase: '#E5E7EB',
skeletonShimmer: '#F3F4F6',
};
}
/**
* Show the skeleton loader
*/
function showSkeleton() {
const skeleton = document.getElementById('chart-skeleton');
const canvas = document.getElementById('monthlyChart');
const error = document.getElementById('chart-error');
if (skeleton) skeleton.style.display = '';
if (canvas) canvas.style.opacity = '0';
if (error) error.classList.add('hidden');
}
/**
* Hide the skeleton and show the chart canvas
*/
function hideSkeleton() {
const skeleton = document.getElementById('chart-skeleton');
const canvas = document.getElementById('monthlyChart');
if (skeleton) {
// Fade out skeleton
skeleton.style.transition = 'opacity 0.4s ease-out';
skeleton.style.opacity = '0';
setTimeout(() => {
if (skeleton) skeleton.style.display = 'none';
}, 400);
}
if (canvas) {
setTimeout(() => {
canvas.style.opacity = '1';
}, 200);
}
}
/**
* Show error state
*/
function showError() {
const skeleton = document.getElementById('chart-skeleton');
const error = document.getElementById('chart-error');
if (skeleton) skeleton.style.display = 'none';
if (error) {
error.classList.remove('hidden');
error.style.display = 'flex';
}
}
/**
* Hide error state
*/
function hideError() {
const error = document.getElementById('chart-error');
if (error) {
error.classList.add('hidden');
error.style.display = 'none';
}
}
/**
* Create gradient fill for the chart
*/
function createGradient(ctx, colors) {
const gradient = ctx.createLinearGradient(0, 0, 0, ctx.canvas.clientHeight);
gradient.addColorStop(0, colors.fillStart);
gradient.addColorStop(1, colors.fillEnd);
return gradient;
}
let monthlyViewChart = null;
/**
* Render the chart with data
*/
function renderChart(data) {
const colors = getChartColors();
const canvas = document.getElementById('monthlyChart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
// Destroy previous chart instance if it exists
if (monthlyViewChart) {
monthlyViewChart.destroy();
monthlyViewChart = null;
}
const gradient = createGradient(ctx, colors);
const chartData = {
labels: data.map((entry) => entry.date),
datasets: [{
label: 'Views',
fill: true,
backgroundColor: gradient,
borderColor: colors.borderColor,
borderWidth: 2.5,
pointBackgroundColor: colors.pointColor,
pointBorderColor: colors.pointColor,
pointHoverBackgroundColor: colors.pointHoverColor,
pointHoverBorderColor: colors.borderColor,
pointHoverBorderWidth: 2,
pointHoverRadius: 6,
pointRadius: 2.5,
pointHitRadius: 20,
cubicInterpolationMode: 'monotone',
tension: 0.4,
data: data.map((entry) => entry.count),
}]
};
const config = {
type: 'line',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 1200,
easing: 'easeOutQuart',
},
plugins: {
title: {
display: false,
},
legend: {
display: false,
},
tooltip: {
backgroundColor: 'rgba(17, 17, 17, 0.95)',
titleColor: '#ffffff',
bodyColor: '#D1D5DB',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
padding: 12,
cornerRadius: 10,
displayColors: false,
bodyFont: {
size: 13,
},
titleFont: {
size: 12,
weight: '600',
},
callbacks: {
label: function(context) {
return 'Views: ' + new Intl.NumberFormat().format(context.parsed.y);
},
title: function(context) {
return context[0].label;
}
}
},
},
interaction: {
intersect: false,
mode: 'index',
},
scales: {
x: {
display: true,
grid: {
color: colors.gridColor,
drawBorder: false,
},
ticks: {
color: colors.textColor,
font: {
size: 11,
},
maxTicksLimit: 14,
maxRotation: 0,
},
title: {
display: false,
}
},
y: {
display: true,
beginAtZero: true,
grid: {
color: colors.gridColor,
drawBorder: false,
},
ticks: {
color: colors.textColor,
font: {
size: 11,
},
callback: function(value) {
if (value >= 1000000) return (value / 1000000).toFixed(1) + 'M';
if (value >= 1000) return (value / 1000).toFixed(1) + 'K';
return value;
},
},
title: {
display: false,
},
}
}
},
};
hideError();
monthlyViewChart = new Chart(canvas, config);
hideSkeleton();
}
/**
* Fetch chart data from the API
*/
async function fetchChartData() {
showSkeleton();
hideError();
try {
const response = await window.axios.get('/v1/monthly-views');
if (response.status !== 200 || !response.data || response.data.length === 0) {
throw new Error('Invalid or empty response');
}
renderChart(response.data);
} catch (error) {
console.error('Failed to load chart data:', error);
showError();
}
}
/**
* Retry loading the chart (called from error state button)
*/
window.retryChart = function() {
if (monthlyViewChart) {
monthlyViewChart.destroy();
monthlyViewChart = null;
}
fetchChartData();
};
// Listen for theme changes to re-render chart
const themeObserver = new MutationObserver(() => {
if (monthlyViewChart) {
const data = monthlyViewChart.data.datasets[0].data.map((value, index) => ({
date: monthlyViewChart.data.labels[index],
count: value,
}));
renderChart(data);
}
});
// Observe theme class changes on html element
const htmlElement = document.documentElement;
if (htmlElement) {
themeObserver.observe(htmlElement, { attributes: true, attributeFilter: ['class'] });
}
// Start the fetch when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
// Small delay to ensure the page has rendered and skeleton is visible
setTimeout(() => {
fetchChartData();
}, 300);
});
// Handle window resize for theme changes (system preference)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (!('theme' in localStorage) && monthlyViewChart) {
const data = monthlyViewChart.data.datasets[0].data.map((value, index) => ({
date: monthlyViewChart.data.labels[index],
count: value,
}));
renderChart(data);
}
});