Update stats page design
This commit is contained in:
@@ -3,11 +3,13 @@
|
|||||||
namespace App\Helpers;
|
namespace App\Helpers;
|
||||||
|
|
||||||
use App\Models\Comment;
|
use App\Models\Comment;
|
||||||
|
use App\Models\Downloads;
|
||||||
use App\Models\Episode;
|
use App\Models\Episode;
|
||||||
use App\Models\Hentai;
|
use App\Models\Hentai;
|
||||||
use App\Models\PopularDaily;
|
use App\Models\PopularDaily;
|
||||||
use App\Models\PopularMonthly;
|
use App\Models\PopularMonthly;
|
||||||
use App\Models\PopularWeekly;
|
use App\Models\PopularWeekly;
|
||||||
|
use App\Models\User;
|
||||||
use Conner\Tagging\Model\Tag;
|
use Conner\Tagging\Model\Tag;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -61,6 +63,100 @@ class CacheHelper
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getTotalUserCount()
|
||||||
|
{
|
||||||
|
return Cache::remember('total_user_count', now()->addMinutes(60), function () {
|
||||||
|
return User::count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getTotalCommentCount()
|
||||||
|
{
|
||||||
|
return Cache::remember('total_comment_count', now()->addMinutes(60), function () {
|
||||||
|
return Comment::count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getTotalLikeCount()
|
||||||
|
{
|
||||||
|
return Cache::remember('total_like_count', now()->addMinutes(60), function () {
|
||||||
|
return DB::table('markable_likes')->count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getTotalDownloadCount()
|
||||||
|
{
|
||||||
|
return Cache::remember('total_download_count', now()->addMinutes(60), function () {
|
||||||
|
return Downloads::sum('count');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get4kEpisodeCount()
|
||||||
|
{
|
||||||
|
return Cache::remember('episodes_4k_count', now()->addMinutes(60), function () {
|
||||||
|
return Episode::where('interpolated', true)->count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getUHD48FpsEpisodeCount()
|
||||||
|
{
|
||||||
|
return Cache::remember('episodes_uhd48_count', now()->addMinutes(60), function () {
|
||||||
|
return Episode::where('interpolated_uhd', true)->count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getTodayViewCount()
|
||||||
|
{
|
||||||
|
return Cache::remember('today_view_count', now()->addMinutes(30), function () {
|
||||||
|
return PopularDaily::whereDate('created_at', today())->count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getWeeklyViewCount()
|
||||||
|
{
|
||||||
|
return Cache::remember('weekly_view_count', now()->addMinutes(60), function () {
|
||||||
|
return PopularWeekly::whereDate('created_at', '>=', today()->subDays(7))->count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPreviousWeeklyViewCount()
|
||||||
|
{
|
||||||
|
return Cache::remember('prev_weekly_view_count', now()->addMinutes(60), function () {
|
||||||
|
return PopularWeekly::whereDate('created_at', '>=', today()->subDays(14))
|
||||||
|
->whereDate('created_at', '<', today()->subDays(7))
|
||||||
|
->count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getAverageViewsPerEpisode()
|
||||||
|
{
|
||||||
|
return Cache::remember('avg_views_per_episode', now()->addMinutes(60), function () {
|
||||||
|
$totalEpisodes = Episode::count();
|
||||||
|
if ($totalEpisodes === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round(Episode::sum('view_count') / $totalEpisodes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNewEpisodesThisWeek()
|
||||||
|
{
|
||||||
|
return Cache::remember('new_episodes_this_week', now()->addMinutes(60), function () {
|
||||||
|
return Episode::whereDate('created_at', '>=', today()->subDays(7))->count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getTopTags()
|
||||||
|
{
|
||||||
|
return Cache::remember('top_tags_stats', now()->addMinutes(120), function () {
|
||||||
|
return Tag::where('count', '>', 0)
|
||||||
|
->orderBy('count', 'desc')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public static function getPopularAllTime(bool $guest)
|
public static function getPopularAllTime(bool $guest)
|
||||||
{
|
{
|
||||||
$guestString = $guest ? 'guest' : 'authed';
|
$guestString = $guest ? 'guest' : 'authed';
|
||||||
|
|||||||
@@ -103,6 +103,18 @@ class HomeController extends Controller
|
|||||||
'viewCount' => CacheHelper::getTotalViewCount(),
|
'viewCount' => CacheHelper::getTotalViewCount(),
|
||||||
'episodeCount' => CacheHelper::getTotalEpisodeCount(),
|
'episodeCount' => CacheHelper::getTotalEpisodeCount(),
|
||||||
'hentaiCount' => CacheHelper::getTotalHentaiCount(),
|
'hentaiCount' => CacheHelper::getTotalHentaiCount(),
|
||||||
|
'userCount' => CacheHelper::getTotalUserCount(),
|
||||||
|
'commentCount' => CacheHelper::getTotalCommentCount(),
|
||||||
|
'likeCount' => CacheHelper::getTotalLikeCount(),
|
||||||
|
'downloadCount' => CacheHelper::getTotalDownloadCount(),
|
||||||
|
'episodes4k' => CacheHelper::get4kEpisodeCount(),
|
||||||
|
'episodesUHD48' => CacheHelper::getUHD48FpsEpisodeCount(),
|
||||||
|
'todayViews' => CacheHelper::getTodayViewCount(),
|
||||||
|
'weeklyViews' => CacheHelper::getWeeklyViewCount(),
|
||||||
|
'prevWeeklyViews' => CacheHelper::getPreviousWeeklyViewCount(),
|
||||||
|
'avgViewsPerEpisode' => CacheHelper::getAverageViewsPerEpisode(),
|
||||||
|
'newEpisodesThisWeek' => CacheHelper::getNewEpisodesThisWeek(),
|
||||||
|
'topTags' => CacheHelper::getTopTags(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,3 +146,35 @@ input:checked~.dot {
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stats Page - Shimmer Skeleton Loader */
|
||||||
|
.shimmer-overlay {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(255, 255, 255, 0.4) 50%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 2s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .shimmer-overlay {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(255, 255, 255, 0.05) 50%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+274
-34
@@ -1,73 +1,313 @@
|
|||||||
import Chart from 'chart.js/auto';
|
import Chart from 'chart.js/auto';
|
||||||
|
|
||||||
// Theming
|
/**
|
||||||
if (localStorage.theme !== 'light') {
|
* Theme-aware chart defaults
|
||||||
Chart.defaults.color = "#ADBABD";
|
*/
|
||||||
Chart.defaults.borderColor = "rgba(255,255,255,0.1)";
|
function getChartColors() {
|
||||||
Chart.defaults.backgroundColor = "rgba(255,255,0,0.1)";
|
const isDark = localStorage.theme !== 'light' &&
|
||||||
Chart.defaults.elements.line.borderColor = "rgba(255,255,0,0.4)";
|
(!('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',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Tags from API
|
return {
|
||||||
window.axios.get('/v1/monthly-views').then(function (response) {
|
textColor: '#6B7280',
|
||||||
if (response.status != 200) {
|
gridColor: 'rgba(0, 0, 0, 0.06)',
|
||||||
return;
|
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',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = {
|
/**
|
||||||
labels: response.data.map((entry) => { return entry.date }),
|
* 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: [{
|
datasets: [{
|
||||||
label: 'Views',
|
label: 'Views',
|
||||||
fill: false,
|
fill: true,
|
||||||
backgroundColor: 'rgba(190, 18, 60, 0.3)',
|
backgroundColor: gradient,
|
||||||
borderColor: 'rgba(190, 18, 60, 1.0)',
|
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',
|
cubicInterpolationMode: 'monotone',
|
||||||
data: response.data.map((entry) => { return entry.count }),
|
tension: 0.4,
|
||||||
|
data: data.map((entry) => entry.count),
|
||||||
}]
|
}]
|
||||||
}
|
};
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: data,
|
data: chartData,
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: {
|
||||||
|
duration: 1200,
|
||||||
|
easing: 'easeOutQuart',
|
||||||
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: false,
|
||||||
text: 'Views the last 28 days',
|
},
|
||||||
font: {
|
legend: {
|
||||||
size: 18
|
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: {
|
interaction: {
|
||||||
intersect: false,
|
intersect: false,
|
||||||
|
mode: 'index',
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
display: true,
|
display: true,
|
||||||
|
grid: {
|
||||||
|
color: colors.gridColor,
|
||||||
|
drawBorder: false,
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: colors.textColor,
|
||||||
|
font: {
|
||||||
|
size: 11,
|
||||||
|
},
|
||||||
|
maxTicksLimit: 14,
|
||||||
|
maxRotation: 0,
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
display: true
|
display: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
display: true,
|
display: true,
|
||||||
title: {
|
beginAtZero: true,
|
||||||
display: true,
|
grid: {
|
||||||
text: 'Views'
|
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,
|
||||||
},
|
},
|
||||||
suggestedMin: 0,
|
|
||||||
suggestedMax: 40000
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const monthlyViewChart = new Chart(
|
hideError();
|
||||||
document.getElementById('monthlyChart'),
|
monthlyViewChart = new Chart(canvas, config);
|
||||||
config
|
hideSkeleton();
|
||||||
);
|
}
|
||||||
}).catch(function (error) {
|
|
||||||
console.log(error);
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,78 +1,365 @@
|
|||||||
<x-app-layout>
|
<x-app-layout>
|
||||||
<div class="container mx-auto px-4 py-12 md:py-24">
|
<div class="container mx-auto px-4 py-8 md:py-16 max-w-7xl">
|
||||||
<section class="text-center mb-16">
|
{{-- Header Section --}}
|
||||||
<!-- Logo -->
|
<section class="text-center mb-10 md:mb-14">
|
||||||
<div class="flex justify-center mb-8">
|
<div class="flex justify-center mb-6">
|
||||||
<img
|
<img
|
||||||
src="/images/cropped-HS-1-270x270.webp"
|
src="/images/cropped-HS-1-270x270.webp"
|
||||||
alt="hstream.moe Logo"
|
alt="hstream.moe Logo"
|
||||||
class="max-w-[150px] w-full h-auto rounded-lg"
|
class="max-w-[120px] w-full h-auto rounded-xl shadow-lg hover:scale-105 transition-transform duration-300"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<h1 class="text-3xl md:text-4xl font-extrabold text-gray-900 dark:text-white mb-2 tracking-tight">
|
||||||
<!-- Stats Grid -->
|
Site Statistics
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
|
</h1>
|
||||||
<!-- View Count Card -->
|
<p class="text-gray-500 dark:text-neutral-400 text-sm md:text-base max-w-lg mx-auto">
|
||||||
<div class="bg-sky-300/50 dark:bg-sky-950/50 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow duration-300">
|
A comprehensive overview of hstream.moe's content and community activity
|
||||||
<div class="flex justify-center mb-4">
|
</p>
|
||||||
<i class="fa-solid fa-eye text-4xl text-sky-600 dark:text-sky-400 p-3"></i>
|
<div class="mt-5 flex justify-center">
|
||||||
</div>
|
<div class="inline-flex items-center gap-2 px-4 py-1.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-xs font-semibold">
|
||||||
<div class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
<span class="relative flex h-2 w-2">
|
||||||
{{ number_format($viewCount) }}
|
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||||
</div>
|
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||||
<h5 class="text-lg font-medium text-gray-700 dark:text-neutral-300">
|
</span>
|
||||||
total views
|
Live data · Updated hourly
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Episode Count Card -->
|
|
||||||
<div class="bg-sky-300/50 dark:bg-sky-950/50 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow duration-300">
|
|
||||||
<div class="flex justify-center mb-4">
|
|
||||||
<i class="fa-solid fa-video text-4xl text-sky-600 dark:text-sky-400 p-3"></i>
|
|
||||||
</div>
|
|
||||||
<div class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
|
||||||
{{ $episodeCount }}
|
|
||||||
</div>
|
|
||||||
<h5 class="text-lg font-medium text-gray-700 dark:text-neutral-300">
|
|
||||||
episodes on this site
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hentai Count Card -->
|
|
||||||
<div class="bg-rose-300/50 dark:bg-rose-950/50 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow duration-300">
|
|
||||||
<div class="flex justify-center mb-4">
|
|
||||||
<i class="fa-solid fa-list text-4xl text-rose-600 dark:text-rose-400 p-3"></i>
|
|
||||||
</div>
|
|
||||||
<div class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
|
||||||
{{ $hentaiCount }}
|
|
||||||
</div>
|
|
||||||
<h5 class="text-lg font-medium text-gray-700 dark:text-neutral-300">
|
|
||||||
hentais on this site
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Watch Time Card -->
|
|
||||||
<div class="bg-rose-300/50 dark:bg-rose-950/50 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow duration-300">
|
|
||||||
<div class="flex justify-center mb-4">
|
|
||||||
<i class="fa-solid fa-clock text-4xl text-rose-600 dark:text-rose-400 p-3"></i>
|
|
||||||
</div>
|
|
||||||
<div class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
|
||||||
{{ number_format($viewCount * 6) }}
|
|
||||||
</div>
|
|
||||||
<h5 class="text-lg font-medium text-gray-700 dark:text-neutral-300">
|
|
||||||
estimated minutes of watch time
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chart Container -->
|
|
||||||
<div class="mt-12 mx-auto max-w-4xl">
|
|
||||||
<div class="bg-gray-50 dark:bg-neutral-950 rounded-xl p-4 md:p-6 shadow-inner hidden sm:block">
|
|
||||||
<canvas id="monthlyChart" class="w-full h-64 md:h-80"></canvas>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{{-- Primary Stats Grid --}}
|
||||||
|
<section class="mb-8">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-5">
|
||||||
|
{{-- Total Views --}}
|
||||||
|
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||||
|
<div class="absolute top-0 right-0 w-24 h-24 bg-sky-400/10 dark:bg-sky-500/10 rounded-bl-[80px] -mr-4 -mt-4"></div>
|
||||||
|
<div class="relative z-10">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<div class="w-9 h-9 rounded-xl bg-sky-100 dark:bg-sky-900/50 flex items-center justify-center">
|
||||||
|
<i class="fa-solid fa-eye text-sky-600 dark:text-sky-400 text-sm"></i>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $viewCount }}">
|
||||||
|
{{ number_format($viewCount) }}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Total Views</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Total Episodes --}}
|
||||||
|
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||||
|
<div class="absolute top-0 right-0 w-24 h-24 bg-violet-400/10 dark:bg-violet-500/10 rounded-bl-[80px] -mr-4 -mt-4"></div>
|
||||||
|
<div class="relative z-10">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<div class="w-9 h-9 rounded-xl bg-violet-100 dark:bg-violet-900/50 flex items-center justify-center">
|
||||||
|
<i class="fa-solid fa-video text-violet-600 dark:text-violet-400 text-sm"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $episodeCount }}">
|
||||||
|
{{ number_format($episodeCount) }}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Episodes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Total Series --}}
|
||||||
|
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||||
|
<div class="absolute top-0 right-0 w-24 h-24 bg-rose-400/10 dark:bg-rose-500/10 rounded-bl-[80px] -mr-4 -mt-4"></div>
|
||||||
|
<div class="relative z-10">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<div class="w-9 h-9 rounded-xl bg-rose-100 dark:bg-rose-900/50 flex items-center justify-center">
|
||||||
|
<i class="fa-solid fa-list text-rose-600 dark:text-rose-400 text-sm"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $hentaiCount }}">
|
||||||
|
{{ number_format($hentaiCount) }}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Series</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Total Users --}}
|
||||||
|
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||||
|
<div class="absolute top-0 right-0 w-24 h-24 bg-emerald-400/10 dark:bg-emerald-500/10 rounded-bl-[80px] -mr-4 -mt-4"></div>
|
||||||
|
<div class="relative z-10">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<div class="w-9 h-9 rounded-xl bg-emerald-100 dark:bg-emerald-900/50 flex items-center justify-center">
|
||||||
|
<i class="fa-solid fa-users text-emerald-600 dark:text-emerald-400 text-sm"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $userCount }}">
|
||||||
|
{{ number_format($userCount) }}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Registered Users</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Secondary Stats Grid --}}
|
||||||
|
<section class="mb-8">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-5">
|
||||||
|
{{-- Total Likes --}}
|
||||||
|
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||||
|
<div class="relative z-10">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<div class="w-9 h-9 rounded-xl bg-red-100 dark:bg-red-900/50 flex items-center justify-center">
|
||||||
|
<i class="fa-solid fa-heart text-red-500 dark:text-red-400 text-sm"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $likeCount }}">
|
||||||
|
{{ number_format($likeCount) }}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Total Likes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Total Comments --}}
|
||||||
|
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||||
|
<div class="relative z-10">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<div class="w-9 h-9 rounded-xl bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center">
|
||||||
|
<i class="fa-solid fa-comments text-amber-600 dark:text-amber-400 text-sm"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $commentCount }}">
|
||||||
|
{{ number_format($commentCount) }}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Comments</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Total Downloads --}}
|
||||||
|
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||||
|
<div class="relative z-10">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<div class="w-9 h-9 rounded-xl bg-blue-100 dark:bg-blue-900/50 flex items-center justify-center">
|
||||||
|
<i class="fa-solid fa-download text-blue-600 dark:text-blue-400 text-sm"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $downloadCount }}">
|
||||||
|
{{ number_format($downloadCount) }}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Downloads</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Avg Views Per Episode --}}
|
||||||
|
<div class="relative overflow-hidden bg-white dark:bg-neutral-900 rounded-2xl p-4 md:p-6 shadow-sm border border-gray-100 dark:border-neutral-800 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 group">
|
||||||
|
<div class="relative z-10">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<div class="w-9 h-9 rounded-xl bg-orange-100 dark:bg-orange-900/50 flex items-center justify-center">
|
||||||
|
<i class="fa-solid fa-chart-simple text-orange-600 dark:text-orange-400 text-sm"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $avgViewsPerEpisode }}">
|
||||||
|
{{ number_format($avgViewsPerEpisode) }}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400 font-medium">Avg Views / Episode</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Tertiary Stats Row: Today, This Week, New Episodes, 4K Content --}}
|
||||||
|
<section class="mb-10">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-5">
|
||||||
|
{{-- Today's Views --}}
|
||||||
|
<div class="relative overflow-hidden bg-gradient-to-br from-sky-50 to-sky-100/50 dark:from-sky-950/40 dark:to-sky-900/20 rounded-2xl p-4 md:p-6 shadow-sm border border-sky-200/50 dark:border-sky-800/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
|
||||||
|
<div class="relative z-10">
|
||||||
|
<p class="text-xs text-sky-600 dark:text-sky-400 font-semibold uppercase tracking-wider mb-2">Today</p>
|
||||||
|
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $todayViews }}">
|
||||||
|
{{ number_format($todayViews) }}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-neutral-400 font-medium">views so far</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- This Week's Views with Trend --}}
|
||||||
|
<div class="relative overflow-hidden bg-gradient-to-br from-violet-50 to-violet-100/50 dark:from-violet-950/40 dark:to-violet-900/20 rounded-2xl p-4 md:p-6 shadow-sm border border-violet-200/50 dark:border-violet-800/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
|
||||||
|
<div class="relative z-10">
|
||||||
|
<p class="text-xs text-violet-600 dark:text-violet-400 font-semibold uppercase tracking-wider mb-2">Views This Week</p>
|
||||||
|
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $weeklyViews }}">
|
||||||
|
{{ number_format($weeklyViews) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
@php
|
||||||
|
$trend = $prevWeeklyViews > 0 ? round((($weeklyViews - $prevWeeklyViews) / $prevWeeklyViews) * 100) : 0;
|
||||||
|
@endphp
|
||||||
|
@if($trend > 0)
|
||||||
|
<i class="fa-solid fa-arrow-trend-up text-green-500 text-xs"></i>
|
||||||
|
<span class="text-xs text-green-600 dark:text-green-400 font-semibold">{{ $trend }}% vs last week</span>
|
||||||
|
@elseif($trend < 0)
|
||||||
|
<i class="fa-solid fa-arrow-trend-down text-red-500 text-xs"></i>
|
||||||
|
<span class="text-xs text-red-600 dark:text-red-400 font-semibold">{{ abs($trend) }}% vs last week</span>
|
||||||
|
@else
|
||||||
|
<span class="text-xs text-gray-500 dark:text-neutral-400 font-medium">same as last week</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- New Episodes This Week --}}
|
||||||
|
<div class="relative overflow-hidden bg-gradient-to-br from-emerald-50 to-emerald-100/50 dark:from-emerald-950/40 dark:to-emerald-900/20 rounded-2xl p-4 md:p-6 shadow-sm border border-emerald-200/50 dark:border-emerald-800/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
|
||||||
|
<div class="relative z-10">
|
||||||
|
<p class="text-xs text-emerald-600 dark:text-emerald-400 font-semibold uppercase tracking-wider mb-2">New This Week</p>
|
||||||
|
<div class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1 stat-counter" data-target="{{ $newEpisodesThisWeek }}">
|
||||||
|
{{ number_format($newEpisodesThisWeek) }}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-neutral-400 font-medium">episodes added</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 4K / 48fps Content --}}
|
||||||
|
<div class="relative overflow-hidden bg-gradient-to-br from-rose-50 to-rose-100/50 dark:from-rose-950/40 dark:to-rose-900/20 rounded-2xl p-4 md:p-6 shadow-sm border border-rose-200/50 dark:border-rose-800/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
|
||||||
|
<div class="relative z-10">
|
||||||
|
<p class="text-xs text-rose-600 dark:text-rose-400 font-semibold uppercase tracking-wider mb-2">High Quality</p>
|
||||||
|
<div class="flex items-baseline gap-2 mb-1">
|
||||||
|
<span class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white stat-counter" data-target="{{ $episodes4k }}">{{ number_format($episodes4k) }}</span>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-neutral-400">4K</span>
|
||||||
|
<span class="text-gray-300 dark:text-neutral-700">/</span>
|
||||||
|
<span class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white stat-counter" data-target="{{ $episodesUHD48 }}">{{ number_format($episodesUHD48) }}</span>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-neutral-400">48fps</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-neutral-400 font-medium">4K & 48fps content</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Views Chart Section --}}
|
||||||
|
<section class="mb-10">
|
||||||
|
<div class="bg-white dark:bg-neutral-900 rounded-2xl shadow-sm border border-gray-100 dark:border-neutral-800 p-4 md:p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-bold text-gray-900 dark:text-white">Views Over Time</h2>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-neutral-400">Daily views for the past 28 days</p>
|
||||||
|
</div>
|
||||||
|
<div class="hidden sm:flex items-center gap-3 text-xs text-gray-500 dark:text-neutral-400">
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="w-2.5 h-2.5 rounded-full bg-rose-500"></span> Views
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Chart wrapper with skeleton loader --}}
|
||||||
|
<div class="relative min-h-[300px] md:min-h-[350px]">
|
||||||
|
{{-- Skeleton Loader --}}
|
||||||
|
<div id="chart-skeleton" class="absolute inset-0 flex flex-col justify-end px-1 pb-6 z-10">
|
||||||
|
<div class="flex items-end justify-between gap-1 md:gap-2 h-full">
|
||||||
|
@for ($i = 0; $i < 28; $i++)
|
||||||
|
@php
|
||||||
|
$heights = [55, 40, 65, 35, 60, 45, 70, 50, 75, 38, 58, 42, 80, 48, 68, 33, 62, 44, 72, 52, 55, 40, 65, 58, 78, 46, 63, 50];
|
||||||
|
$h = $heights[$i % count($heights)];
|
||||||
|
@endphp
|
||||||
|
<div class="flex-1 flex flex-col items-center justify-end gap-1">
|
||||||
|
<div class="w-full rounded-md bg-gray-200 dark:bg-neutral-800 animate-pulse" style="height: {{ $h }}%; min-height: 8px;"></div>
|
||||||
|
</div>
|
||||||
|
@endfor
|
||||||
|
</div>
|
||||||
|
{{-- Shimmer overlay --}}
|
||||||
|
<div class="absolute inset-0 shimmer-overlay rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Actual Chart Canvas --}}
|
||||||
|
<canvas id="monthlyChart" class="w-full h-[300px] md:h-[350px] relative z-0 opacity-0 transition-opacity duration-500"></canvas>
|
||||||
|
|
||||||
|
{{-- Error State --}}
|
||||||
|
<div id="chart-error" class="absolute inset-0 hidden flex-col items-center justify-center bg-white/90 dark:bg-neutral-900/90 rounded-lg z-20">
|
||||||
|
<div class="w-14 h-14 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-3">
|
||||||
|
<i class="fa-solid fa-triangle-exclamation text-red-500 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-neutral-300 font-medium">Unable to load chart data</p>
|
||||||
|
<button onclick="retryChart()" class="mt-3 px-4 py-2 text-xs font-semibold text-white bg-rose-600 hover:bg-rose-700 rounded-lg transition-colors">
|
||||||
|
<i class="fa-solid fa-rotate mr-1"></i> Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Mobile legend --}}
|
||||||
|
<div class="flex sm:hidden items-center justify-center gap-3 mt-3 text-xs text-gray-500 dark:text-neutral-400">
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="w-2.5 h-2.5 rounded-full bg-rose-500"></span> Views
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Bottom Section: Top Tags --}}
|
||||||
|
@if($topTags->isNotEmpty())
|
||||||
|
<section class="mb-8">
|
||||||
|
<div class="bg-white dark:bg-neutral-900 rounded-2xl shadow-sm border border-gray-100 dark:border-neutral-800 p-4 md:p-6">
|
||||||
|
<h2 class="text-lg font-bold text-gray-900 dark:text-white mb-4">Most Popular Tags</h2>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@foreach($topTags as $tag)
|
||||||
|
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold
|
||||||
|
bg-gray-100 dark:bg-neutral-800 text-gray-700 dark:text-neutral-300
|
||||||
|
hover:bg-rose-100 dark:hover:bg-rose-900/40 hover:text-rose-700 dark:hover:text-rose-400
|
||||||
|
transition-colors duration-200 cursor-default">
|
||||||
|
#{{ $tag->name }}
|
||||||
|
<span class="text-[10px] text-gray-400 dark:text-neutral-500">{{ number_format($tag->count) }}</span>
|
||||||
|
</span>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Footer Note --}}
|
||||||
|
<div class="text-center text-xs text-gray-400 dark:text-neutral-600">
|
||||||
|
Statistics are cached and update periodically. Data shown may not reflect real-time changes.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Animated Counter Script --}}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const animateCounters = () => {
|
||||||
|
document.querySelectorAll('.stat-counter').forEach(counter => {
|
||||||
|
const target = parseInt(counter.dataset.target, 10);
|
||||||
|
if (isNaN(target)) return;
|
||||||
|
|
||||||
|
const duration = 1500;
|
||||||
|
const start = performance.now();
|
||||||
|
const startVal = 0;
|
||||||
|
|
||||||
|
const step = (currentTime) => {
|
||||||
|
const elapsed = currentTime - start;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
// Ease out cubic
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3);
|
||||||
|
const current = Math.round(startVal + (target - startVal) * eased);
|
||||||
|
|
||||||
|
const formatted = new Intl.NumberFormat().format(current);
|
||||||
|
counter.textContent = formatted;
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use IntersectionObserver to trigger counters when visible
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
animateCounters();
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { threshold: 0.3 });
|
||||||
|
|
||||||
|
const firstCounter = document.querySelector('.stat-counter');
|
||||||
|
if (firstCounter) {
|
||||||
|
observer.observe(firstCounter);
|
||||||
|
} else {
|
||||||
|
// Fallback
|
||||||
|
animateCounters();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
@vite(['resources/js/stats.js'])
|
@vite(['resources/js/stats.js'])
|
||||||
</x-app-layout>
|
</x-app-layout>
|
||||||
Reference in New Issue
Block a user