From 7553b9f8957f7eb73cb8cae0532c9895338268e8 Mon Sep 17 00:00:00 2001 From: w33b Date: Sat, 25 Jul 2026 14:18:50 +0200 Subject: [PATCH] Update stats page design --- app/Helpers/CacheHelper.php | 98 +++++- app/Http/Controllers/HomeController.php | 12 + resources/css/app.css | 32 ++ resources/js/stats.js | 312 ++++++++++++++++-- resources/views/home/stats.blade.php | 419 ++++++++++++++++++++---- 5 files changed, 770 insertions(+), 103 deletions(-) diff --git a/app/Helpers/CacheHelper.php b/app/Helpers/CacheHelper.php index 419c9d8..ba8b339 100644 --- a/app/Helpers/CacheHelper.php +++ b/app/Helpers/CacheHelper.php @@ -3,11 +3,13 @@ namespace App\Helpers; use App\Models\Comment; +use App\Models\Downloads; use App\Models\Episode; use App\Models\Hentai; use App\Models\PopularDaily; use App\Models\PopularMonthly; use App\Models\PopularWeekly; +use App\Models\User; use Conner\Tagging\Model\Tag; use Illuminate\Support\Facades\Cache; 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) { $guestString = $guest ? 'guest' : 'authed'; @@ -131,4 +227,4 @@ class CacheHelper return Comment::with('user')->latest()->take(10)->get(); }); } -} +} \ No newline at end of file diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 760f7ce..352ef88 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -103,6 +103,18 @@ class HomeController extends Controller 'viewCount' => CacheHelper::getTotalViewCount(), 'episodeCount' => CacheHelper::getTotalEpisodeCount(), '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(), ]); } diff --git a/resources/css/app.css b/resources/css/app.css index abfaf3f..a188059 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -146,3 +146,35 @@ input:checked~.dot { :root { 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; + } +} diff --git a/resources/js/stats.js b/resources/js/stats.js index ba1baf2..6b4f5a3 100644 --- a/resources/js/stats.js +++ b/resources/js/stats.js @@ -1,73 +1,313 @@ import Chart from 'chart.js/auto'; -// Theming -if (localStorage.theme !== 'light') { - Chart.defaults.color = "#ADBABD"; - Chart.defaults.borderColor = "rgba(255,255,255,0.1)"; - Chart.defaults.backgroundColor = "rgba(255,255,0,0.1)"; - Chart.defaults.elements.line.borderColor = "rgba(255,255,0,0.4)"; +/** + * 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', + }; } -// Get Tags from API -window.axios.get('/v1/monthly-views').then(function (response) { - if (response.status != 200) { - return; - } +/** + * 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'); +} - const data = { - labels: response.data.map((entry) => { return entry.date }), +/** + * 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: false, - backgroundColor: 'rgba(190, 18, 60, 0.3)', - borderColor: 'rgba(190, 18, 60, 1.0)', + 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', - data: response.data.map((entry) => { return entry.count }), + tension: 0.4, + data: data.map((entry) => entry.count), }] - } + }; const config = { type: 'line', - data: data, + data: chartData, options: { responsive: true, + maintainAspectRatio: false, + animation: { + duration: 1200, + easing: 'easeOutQuart', + }, plugins: { title: { - display: true, - text: 'Views the last 28 days', - font: { - size: 18 + 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: true + display: false, } }, y: { display: true, - title: { - display: true, - text: 'Views' + 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, }, - suggestedMin: 0, - suggestedMax: 40000 } } }, - }; + }; - const monthlyViewChart = new Chart( - document.getElementById('monthlyChart'), - config - ); -}).catch(function (error) { - console.log(error); + 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); + } +}); \ No newline at end of file diff --git a/resources/views/home/stats.blade.php b/resources/views/home/stats.blade.php index 3d4c264..dcc5290 100644 --- a/resources/views/home/stats.blade.php +++ b/resources/views/home/stats.blade.php @@ -1,78 +1,365 @@ -
-
- -
+
+ {{-- Header Section --}} +
+
hstream.moe Logo
- - -
- -
-
- -
-
- {{ number_format($viewCount) }} -
-
- total views -
-
- - -
-
- -
-
- {{ $episodeCount }} -
-
- episodes on this site -
-
- - -
-
- -
-
- {{ $hentaiCount }} -
-
- hentais on this site -
-
- - -
-
- -
-
- {{ number_format($viewCount * 6) }} -
-
- estimated minutes of watch time -
-
-
- - -
-
+ + {{-- Primary Stats Grid --}} +
+
+ {{-- Total Views --}} +
+
+
+
+
+ +
+
+
+ {{ number_format($viewCount) }} +
+

Total Views

+
+
+ + {{-- Total Episodes --}} +
+
+
+
+
+ +
+
+
+ {{ number_format($episodeCount) }} +
+

Episodes

+
+
+ + {{-- Total Series --}} +
+
+
+
+
+ +
+
+
+ {{ number_format($hentaiCount) }} +
+

Series

+
+
+ + {{-- Total Users --}} +
+
+
+
+
+ +
+
+
+ {{ number_format($userCount) }} +
+

Registered Users

+
+
+
+
+ + {{-- Secondary Stats Grid --}} +
+
+ {{-- Total Likes --}} +
+
+
+
+ +
+
+
+ {{ number_format($likeCount) }} +
+

Total Likes

+
+
+ + {{-- Total Comments --}} +
+
+
+
+ +
+
+
+ {{ number_format($commentCount) }} +
+

Comments

+
+
+ + {{-- Total Downloads --}} +
+
+
+
+ +
+
+
+ {{ number_format($downloadCount) }} +
+

Downloads

+
+
+ + {{-- Avg Views Per Episode --}} +
+
+
+
+ +
+
+
+ {{ number_format($avgViewsPerEpisode) }} +
+

Avg Views / Episode

+
+
+
+
+ + {{-- Tertiary Stats Row: Today, This Week, New Episodes, 4K Content --}} +
+
+ {{-- Today's Views --}} +
+
+

Today

+
+ {{ number_format($todayViews) }} +
+

views so far

+
+
+ + {{-- This Week's Views with Trend --}} +
+
+

Views This Week

+
+ {{ number_format($weeklyViews) }} +
+
+ @php + $trend = $prevWeeklyViews > 0 ? round((($weeklyViews - $prevWeeklyViews) / $prevWeeklyViews) * 100) : 0; + @endphp + @if($trend > 0) + + {{ $trend }}% vs last week + @elseif($trend < 0) + + {{ abs($trend) }}% vs last week + @else + same as last week + @endif +
+
+
+ + {{-- New Episodes This Week --}} +
+
+

New This Week

+
+ {{ number_format($newEpisodesThisWeek) }} +
+

episodes added

+
+
+ + {{-- 4K / 48fps Content --}} +
+
+

High Quality

+
+ {{ number_format($episodes4k) }} + 4K + / + {{ number_format($episodesUHD48) }} + 48fps +
+

4K & 48fps content

+
+
+
+
+ + {{-- Views Chart Section --}} +
+
+
+
+

Views Over Time

+

Daily views for the past 28 days

+
+ +
+ + {{-- Chart wrapper with skeleton loader --}} +
+ {{-- Skeleton Loader --}} +
+
+ @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 +
+
+
+ @endfor +
+ {{-- Shimmer overlay --}} +
+
+ + {{-- Actual Chart Canvas --}} + + + {{-- Error State --}} + +
+ + {{-- Mobile legend --}} +
+ + Views + +
+
+
+ + {{-- Bottom Section: Top Tags --}} + @if($topTags->isNotEmpty()) +
+
+

Most Popular Tags

+
+ @foreach($topTags as $tag) + + #{{ $tag->name }} + {{ number_format($tag->count) }} + + @endforeach +
+
+
+ @endif + + {{-- Footer Note --}} +
+ Statistics are cached and update periodically. Data shown may not reflect real-time changes. +
+ {{-- Animated Counter Script --}} + + @vite(['resources/js/stats.js']) - + \ No newline at end of file