add website to view stats

This commit is contained in:
itsAzaria
2026-02-20 12:44:52 +00:00
parent e34435755e
commit 16999ecea5
9 changed files with 744 additions and 12 deletions
+2 -10
View File
@@ -2,6 +2,7 @@
namespace App; namespace App;
use App\Http\Controllers\HomepageController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Laracord\Laracord; use Laracord\Laracord;
@@ -12,15 +13,6 @@ class Bot extends Laracord
*/ */
public function routes(): void public function routes(): void
{ {
Route::middleware('web')->group(function () { Route::get('/', HomepageController::class);
// Route::get('/', fn () => 'Hello world!');
});
Route::middleware('api')->group(function () {
// Route::get('/commands', fn () => collect($this->registeredCommands)->map(fn ($command) => [
// 'signature' => $command->getSignature(),
// 'description' => $command->getDescription(),
// ]));
});
} }
} }
@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers;
use App\Models\EmoteGuildStat;
use App\Models\EmoteLog;
use App\Models\UserGuildStat;
use Illuminate\Http\Request;
class HomepageController
{
/**
* Handle the incoming request.
*/
public function __invoke(Request $request)
{
$rawUserIdFilter = trim((string) $request->query('user_id', ''));
$userIdFilter = ctype_digit($rawUserIdFilter) ? $rawUserIdFilter : '';
$isFiltered = $userIdFilter !== '';
$validationErrors = [];
if ($rawUserIdFilter !== '' && ! ctype_digit($rawUserIdFilter)) {
$validationErrors['user_id'] = 'User ID must contain only numbers.';
}
$stats = $this->buildStats($userIdFilter, $isFiltered);
return view('components.homepage', [
'stats' => $stats,
'userIdInput' => $rawUserIdFilter,
'validationErrors' => $validationErrors,
]);
}
/**
* Build homepage stats.
*/
protected function buildStats(string $userIdFilter, bool $isFiltered): array
{
$resolvedUserId = $isFiltered ? $userIdFilter : null;
if ($isFiltered) {
$aggregate = UserGuildStat::dashboardAggregateForUser($userIdFilter);
} else {
$aggregate = EmoteGuildStat::dashboardAggregate();
}
$usageOverTime = EmoteLog::usageOverTime($resolvedUserId, 30);
$topMovers = EmoteLog::topMovers($resolvedUserId, 7, 10);
$uniqueUsers = $isFiltered ? (int) ($aggregate['unique_users'] ?? 0) : EmoteLog::uniqueUsersCount();
return [
'is_filtered' => $isFiltered,
'filtered_user_id' => $isFiltered ? $userIdFilter : null,
'total_usage' => (int) ($aggregate['total_usage'] ?? 0),
'unique_emotes' => (int) ($aggregate['unique_emotes'] ?? 0),
'unique_users' => $uniqueUsers,
'usage_by_type' => $aggregate['usage_by_type'] ?? ['STATIC' => 0, 'ANIMATED' => 0, 'UNICODE' => 0],
'usage_over_time' => $usageOverTime,
'top_movers' => $topMovers,
'top_static' => $aggregate['top_static'] ?? collect(),
'top_animated' => $aggregate['top_animated'] ?? collect(),
'top_unicode' => $aggregate['top_unicode'] ?? collect(),
];
}
}
+107
View File
@@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
class EmoteGuildStat extends Model class EmoteGuildStat extends Model
{ {
@@ -25,4 +26,110 @@ class EmoteGuildStat extends Model
'emote_id' 'emote_id'
); );
} }
/**
* Build aggregate stats for all users.
*/
public static function dashboardAggregate(): array
{
$baseQuery = self::query()
->join('emotes', 'emote_guild_stats.emote_id', '=', 'emotes.emote_id');
$summary = (clone $baseQuery)
->selectRaw('COALESCE(SUM(emote_guild_stats.usage_count), 0) as total_usage')
->selectRaw('COUNT(DISTINCT emote_guild_stats.emote_id) as unique_emotes')
->first();
$usageByType = (clone $baseQuery)
->select('emotes.type')
->selectRaw('COALESCE(SUM(emote_guild_stats.usage_count), 0) as total_usage')
->groupBy('emotes.type')
->pluck('total_usage', 'type');
$unicodeUsage = (int) EmoteLog::query()
->join('emotes', 'emote_logs.emote_id', '=', 'emotes.emote_id')
->where('emotes.type', 'UNICODE')
->count();
$unicodeUniqueEmotes = (int) EmoteLog::query()
->join('emotes', 'emote_logs.emote_id', '=', 'emotes.emote_id')
->where('emotes.type', 'UNICODE')
->distinct('emote_logs.emote_id')
->count('emote_logs.emote_id');
$topUnicode = EmoteLog::query()
->join('emotes', 'emote_logs.emote_id', '=', 'emotes.emote_id')
->where('emotes.type', 'UNICODE')
->select(
'emote_logs.emote_id',
'emotes.emote_name',
'emotes.type',
'emotes.image'
)
->selectRaw('COUNT(*) as total_usage')
->groupBy(
'emote_logs.emote_id',
'emotes.emote_name',
'emotes.type',
'emotes.image'
)
->orderByDesc('total_usage')
->limit(10)
->get();
$baseTotalUsage = (int) ($summary->total_usage ?? 0);
$baseUniqueEmotes = (int) ($summary->unique_emotes ?? 0);
return [
'total_usage' => $baseTotalUsage + $unicodeUsage,
'unique_emotes' => $baseUniqueEmotes + $unicodeUniqueEmotes,
'usage_by_type' => [
'STATIC' => (int) ($usageByType->get('STATIC') ?? 0),
'ANIMATED' => (int) ($usageByType->get('ANIMATED') ?? 0),
'UNICODE' => $unicodeUsage,
],
'top_static' => self::topEmotesByType(
$baseQuery,
'STATIC',
'emote_guild_stats.usage_count',
'emote_guild_stats.emote_id'
),
'top_animated' => self::topEmotesByType(
$baseQuery,
'ANIMATED',
'emote_guild_stats.usage_count',
'emote_guild_stats.emote_id'
),
'top_unicode' => $topUnicode,
];
}
/**
* Build a top-10 emote list by type.
*/
protected static function topEmotesByType(
$baseQuery,
string $type,
string $usageCountColumn,
string $emoteIdColumn
): Collection {
return (clone $baseQuery)
->where('emotes.type', $type)
->select(
$emoteIdColumn.' as emote_id',
'emotes.emote_name',
'emotes.type',
'emotes.image'
)
->selectRaw("COALESCE(SUM({$usageCountColumn}), 0) as total_usage")
->groupBy(
$emoteIdColumn,
'emotes.emote_name',
'emotes.type',
'emotes.image'
)
->orderByDesc('total_usage')
->limit(10)
->get();
}
} }
+188
View File
@@ -3,6 +3,8 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
class EmoteLog extends Model class EmoteLog extends Model
{ {
@@ -36,4 +38,190 @@ class EmoteLog extends Model
{ {
return $this->belongsTo(User::class, 'user_id', 'discord_id'); return $this->belongsTo(User::class, 'user_id', 'discord_id');
} }
/**
* Count distinct users for logs.
*/
public static function uniqueUsersCount(?string $userId = null): int
{
return (int) self::query()
->when($userId !== null, fn ($query) => $query->where('user_id', $userId))
->distinct('user_id')
->count('user_id');
}
/**
* Build usage-over-time dataset.
*/
public static function usageOverTime(?string $userId, int $days = 30): array
{
$today = now();
$startDate = $today->copy()->subDays($days - 1)->startOfDay();
$rows = self::query()
->when($userId !== null, fn ($query) => $query->where('user_id', $userId))
->where('created_at', '>=', $startDate)
->selectRaw('DATE(created_at) as usage_date')
->selectRaw('COUNT(*) as usage_count')
->groupBy('usage_date')
->orderBy('usage_date')
->pluck('usage_count', 'usage_date');
$dateRange = collect(range($days - 1, 0))->map(fn (int $offset) => $today->copy()->subDays($offset));
return [
'labels' => $dateRange->map(fn ($date) => $date->format('m/d'))->values(),
'values' => $dateRange->map(
fn ($date) => (int) ($rows->get($date->format('Y-m-d')) ?? 0)
)->values(),
];
}
/**
* Build stacked daily trend data by emote type.
*/
public static function stackedDailyTrend(?string $userId, int $days = 30): array
{
$today = now();
$startDate = $today->copy()->subDays($days - 1)->startOfDay();
$rows = self::query()
->join('emotes', 'emote_logs.emote_id', '=', 'emotes.emote_id')
->when($userId !== null, fn ($query) => $query->where('emote_logs.user_id', $userId))
->where('emote_logs.created_at', '>=', $startDate)
->selectRaw('DATE(emote_logs.created_at) as usage_date')
->select('emotes.type')
->selectRaw('COUNT(*) as usage_count')
->groupBy('usage_date', 'emotes.type')
->get();
$indexed = $rows->mapWithKeys(function ($row) {
return [$row->usage_date.'|'.$row->type => (int) $row->usage_count];
});
$dateRange = collect(range($days - 1, 0))->map(fn (int $offset) => $today->copy()->subDays($offset));
$labels = $dateRange->map(fn ($date) => $date->format('m/d'))->values();
$valuesForType = function (string $type) use ($dateRange, $indexed) {
return $dateRange->map(function ($date) use ($type, $indexed) {
$key = $date->format('Y-m-d').'|'.$type;
return (int) ($indexed->get($key) ?? 0);
})->values();
};
return [
'labels' => $labels,
'datasets' => [
['label' => 'Static', 'values' => $valuesForType('STATIC')],
['label' => 'Animated', 'values' => $valuesForType('ANIMATED')],
['label' => 'Unicode', 'values' => $valuesForType('UNICODE')],
],
];
}
/**
* Build day/hour heatmap data.
*/
public static function heatmap(?string $userId, int $days = 30): array
{
$startDate = now()->subDays($days - 1)->startOfDay();
$logs = self::query()
->when($userId !== null, fn ($query) => $query->where('user_id', $userId))
->where('created_at', '>=', $startDate)
->get(['created_at']);
$daysOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
$hours = range(0, 23);
$matrix = [];
foreach ($daysOfWeek as $day) {
$matrix[$day] = array_fill(0, 24, 0);
}
foreach ($logs as $log) {
$timestamp = Carbon::parse($log->created_at);
$day = $daysOfWeek[$timestamp->dayOfWeek];
$hour = $timestamp->hour;
$matrix[$day][$hour]++;
}
$max = collect($matrix)->flatten()->max() ?: 0;
return [
'days' => $daysOfWeek,
'hours' => $hours,
'matrix' => $matrix,
'max' => $max,
];
}
/**
* Build top movers from current vs previous window.
*/
public static function topMovers(?string $userId, int $windowDays = 7, int $limit = 10): Collection
{
$now = now();
$currentStart = $now->copy()->subDays($windowDays - 1)->startOfDay();
$previousStart = $currentStart->copy()->subDays($windowDays);
$previousEnd = $currentStart->copy()->subSecond();
$queryForRange = function (Carbon $start, Carbon $end) use ($userId) {
return self::query()
->join('emotes', 'emote_logs.emote_id', '=', 'emotes.emote_id')
->when($userId !== null, fn ($query) => $query->where('emote_logs.user_id', $userId))
->whereBetween('emote_logs.created_at', [$start, $end])
->select('emote_logs.emote_id', 'emotes.emote_name', 'emotes.type', 'emotes.image')
->selectRaw('COUNT(*) as usage_count')
->groupBy('emote_logs.emote_id', 'emotes.emote_name', 'emotes.type', 'emotes.image')
->get();
};
$currentRows = $queryForRange($currentStart, $now->copy()->endOfDay());
$previousRows = $queryForRange($previousStart, $previousEnd);
$metadata = collect();
$currentById = collect();
$previousById = collect();
foreach ($currentRows as $row) {
$metadata->put($row->emote_id, [
'emote_id' => $row->emote_id,
'emote_name' => $row->emote_name,
'type' => $row->type,
'image' => $row->image,
]);
$currentById->put($row->emote_id, (int) $row->usage_count);
}
foreach ($previousRows as $row) {
if (! $metadata->has($row->emote_id)) {
$metadata->put($row->emote_id, [
'emote_id' => $row->emote_id,
'emote_name' => $row->emote_name,
'type' => $row->type,
'image' => $row->image,
]);
}
$previousById->put($row->emote_id, (int) $row->usage_count);
}
return $metadata
->map(function (array $meta, string $emoteId) use ($currentById, $previousById) {
$current = (int) ($currentById->get($emoteId) ?? 0);
$previous = (int) ($previousById->get($emoteId) ?? 0);
$delta = $current - $previous;
return (object) array_merge($meta, [
'current_count' => $current,
'previous_count' => $previous,
'delta' => $delta,
]);
})
->sortByDesc(fn ($row) => abs($row->delta))
->take($limit)
->values();
}
} }
+81
View File
@@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
class UserGuildStat extends Model class UserGuildStat extends Model
{ {
@@ -23,4 +24,84 @@ class UserGuildStat extends Model
{ {
return $this->belongsTo(User::class, 'user_id', 'discord_id'); return $this->belongsTo(User::class, 'user_id', 'discord_id');
} }
/**
* Build aggregate stats for a single user.
*/
public static function dashboardAggregateForUser(string $userId): array
{
$baseQuery = self::query()
->join('emotes', 'user_guild_stats.emote_id', '=', 'emotes.emote_id')
->where('user_guild_stats.user_id', $userId);
$summary = (clone $baseQuery)
->selectRaw('COALESCE(SUM(user_guild_stats.usage_count), 0) as total_usage')
->selectRaw('COUNT(DISTINCT user_guild_stats.emote_id) as unique_emotes')
->selectRaw('COUNT(DISTINCT user_guild_stats.user_id) as unique_users')
->first();
$usageByType = (clone $baseQuery)
->select('emotes.type')
->selectRaw('COALESCE(SUM(user_guild_stats.usage_count), 0) as total_usage')
->groupBy('emotes.type')
->pluck('total_usage', 'type');
return [
'total_usage' => (int) ($summary->total_usage ?? 0),
'unique_emotes' => (int) ($summary->unique_emotes ?? 0),
'unique_users' => (int) ($summary->unique_users ?? 0),
'usage_by_type' => [
'STATIC' => (int) ($usageByType->get('STATIC') ?? 0),
'ANIMATED' => (int) ($usageByType->get('ANIMATED') ?? 0),
'UNICODE' => (int) ($usageByType->get('UNICODE') ?? 0),
],
'top_static' => self::topEmotesByType(
$baseQuery,
'STATIC',
'user_guild_stats.usage_count',
'user_guild_stats.emote_id'
),
'top_animated' => self::topEmotesByType(
$baseQuery,
'ANIMATED',
'user_guild_stats.usage_count',
'user_guild_stats.emote_id'
),
'top_unicode' => self::topEmotesByType(
$baseQuery,
'UNICODE',
'user_guild_stats.usage_count',
'user_guild_stats.emote_id'
),
];
}
/**
* Build a top-10 emote list by type.
*/
protected static function topEmotesByType(
$baseQuery,
string $type,
string $usageCountColumn,
string $emoteIdColumn
): Collection {
return (clone $baseQuery)
->where('emotes.type', $type)
->select(
$emoteIdColumn.' as emote_id',
'emotes.emote_name',
'emotes.type',
'emotes.image'
)
->selectRaw("COALESCE(SUM({$usageCountColumn}), 0) as total_usage")
->groupBy(
$emoteIdColumn,
'emotes.emote_name',
'emotes.type',
'emotes.image'
)
->orderByDesc('total_usage')
->limit(10)
->get();
}
} }
Generated
+1 -1
View File
@@ -8711,5 +8711,5 @@
"php": "^8.2" "php": "^8.2"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.9.0" "plugin-api-version": "2.6.0"
} }
@@ -0,0 +1,283 @@
@php
$placeholderImage = 'data:image/svg+xml;base64,'.base64_encode('<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><rect width="48" height="48" rx="8" fill="#1e1f22"/><text x="24" y="28" text-anchor="middle" font-size="18" fill="#b5bac1">?</text></svg>');
$emoteImageSrc = function ($image) use ($placeholderImage) {
if (empty($image)) {
return $placeholderImage;
}
$binary = (string) $image;
$mime = 'image/png';
if (str_starts_with($binary, 'GIF8')) {
$mime = 'image/gif';
} elseif (substr($binary, 0, 2) === "\xFF\xD8") {
$mime = 'image/jpeg';
}
return 'data:'.$mime.';base64,'.base64_encode($binary);
};
$chartPayload = [
'usage_over_time' => [
'labels' => data_get($stats, 'usage_over_time.labels', []),
'values' => data_get($stats, 'usage_over_time.values', []),
],
];
@endphp
<x-layouts.app>
<div class="w-full max-w-7xl px-4 py-8 mx-auto sm:px-6 lg:px-8 lg:py-10">
<h1 class="mb-8 text-3xl font-semibold text-white">Emoji Stats</h1>
<div class="grid gap-4 mb-8">
<label for="stats-user-id" class="text-sm text-[#b5bac1]">Filter by User ID (optional)</label>
<form method="GET" action="/" class="flex flex-col gap-2 sm:flex-row">
<input
id="stats-user-id"
type="text"
name="user_id"
value="{{ $userIdInput ?? '' }}"
class="w-full px-4 py-3 border rounded-md bg-[#1e1f22] border-[#3f4147] text-[#dbdee1] placeholder-[#7f838b] focus:outline-none focus:ring-2 focus:ring-[#5865f2]"
placeholder="Enter Discord user ID..."
/>
<button type="submit" class="px-5 py-3 font-medium text-white rounded-md bg-[#5865f2] hover:bg-[#4752c4]">Apply</button>
<a href="/" class="px-5 py-3 rounded-md border border-[#3f4147] bg-[#2b2d31] text-[#dbdee1] hover:bg-[#35373c]">Clear</a>
</form>
@if (!empty($validationErrors['user_id']))
<div class="mt-1 text-[#f23f43]">{{ $validationErrors['user_id'] }}</div>
@endif
@if (!empty($stats['is_filtered']))
<p class="text-sm text-[#b5bac1]">
Showing stats for user ID: <strong>{{ $stats['filtered_user_id'] }}</strong>
</p>
@else
<p class="text-sm text-[#b5bac1]">Showing stats for all users.</p>
@endif
</div>
<div class="mb-8 overflow-x-auto lg:overflow-visible">
<div class="flex gap-3 pb-1 min-w-max lg:min-w-0 lg:w-full lg:justify-between">
<div class="p-4 border rounded-lg bg-[#2b2d31] border-[#3f4147] min-w-[180px] lg:min-w-0 lg:flex-1">
<p class="text-xs font-medium uppercase tracking-wide text-[#b5bac1]">Total Emoji Usage</p>
<p class="mt-2 text-2xl font-semibold text-white">{{ $stats['total_usage'] ?? 0 }}</p>
</div>
<div class="p-4 border rounded-lg bg-[#2b2d31] border-[#3f4147] min-w-[180px] lg:min-w-0 lg:flex-1">
<p class="text-xs font-medium uppercase tracking-wide text-[#b5bac1]">Unique Emotes</p>
<p class="mt-2 text-2xl font-semibold text-white">{{ $stats['unique_emotes'] ?? 0 }}</p>
</div>
<div class="p-4 border rounded-lg bg-[#2b2d31] border-[#3f4147] min-w-[180px] lg:min-w-0 lg:flex-1">
<p class="text-xs font-medium uppercase tracking-wide text-[#b5bac1]">Users Included</p>
<p class="mt-2 text-2xl font-semibold text-white">{{ $stats['unique_users'] ?? 0 }}</p>
</div>
<div class="p-4 border rounded-lg bg-[#2b2d31] border-[#3f4147] min-w-[180px] lg:min-w-0 lg:flex-1">
<p class="text-xs font-medium uppercase tracking-wide text-[#b5bac1]">Static Usage</p>
<p class="mt-2 text-2xl font-semibold text-white">{{ $stats['usage_by_type']['STATIC'] ?? 0 }}</p>
</div>
<div class="p-4 border rounded-lg bg-[#2b2d31] border-[#3f4147] min-w-[180px] lg:min-w-0 lg:flex-1">
<p class="text-xs font-medium uppercase tracking-wide text-[#b5bac1]">Animated Usage</p>
<p class="mt-2 text-2xl font-semibold text-white">{{ $stats['usage_by_type']['ANIMATED'] ?? 0 }}</p>
</div>
<div class="p-4 border rounded-lg bg-[#2b2d31] border-[#3f4147] min-w-[180px] lg:min-w-0 lg:flex-1">
<p class="text-xs font-medium uppercase tracking-wide text-[#b5bac1]">Unicode Usage</p>
<p class="mt-2 text-2xl font-semibold text-white">{{ $stats['usage_by_type']['UNICODE'] ?? 0 }}</p>
</div>
</div>
</div>
<div class="grid gap-8">
<div class="space-y-3">
<h3 class="text-xl font-semibold text-white">Emote Usage Over Time (Last 30 Days)</h3>
<div class="p-6 border rounded-lg bg-[#2b2d31] border-[#3f4147] md:p-7">
<canvas id="emoji-usage-over-time-chart"></canvas>
</div>
</div>
<div class="space-y-3">
<h3 class="text-xl font-semibold text-white">Top Movers (Last 7 Days vs Previous 7 Days)</h3>
<div class="overflow-hidden border rounded-lg bg-[#2b2d31] border-[#3f4147]">
@forelse (($stats['top_movers'] ?? collect()) as $emote)
<div class="flex items-center justify-between px-4 py-3.5 border-b border-[#3f4147] last:border-b-0">
<div class="flex items-center gap-3">
<img src="{{ $emoteImageSrc($emote->image) }}" alt="{{ $emote->emote_name }}" class="object-cover w-9 h-9 border rounded border-[#3f4147]" />
<span class="font-medium text-[#dbdee1]">{{ $emote->emote_name }}</span>
</div>
<div class="text-right">
<p class="text-sm text-[#b5bac1]">{{ $emote->previous_count }} {{ $emote->current_count }}</p>
<p class="text-sm font-medium {{ $emote->delta >= 0 ? 'text-[#57f287]' : 'text-[#ed4245]' }}">
{{ $emote->delta >= 0 ? '+' : '' }}{{ $emote->delta }}
</p>
</div>
</div>
@empty
<p class="px-4 py-3 text-sm text-[#b5bac1]">No mover data available yet.</p>
@endforelse
</div>
</div>
<div class="space-y-3">
<h3 class="text-xl font-semibold text-white">Top 10 Static Emotes</h3>
<div class="overflow-hidden border rounded-lg bg-[#2b2d31] border-[#3f4147]">
@forelse (($stats['top_static'] ?? collect()) as $emote)
<div class="flex items-center justify-between px-4 py-3.5 border-b border-[#3f4147] last:border-b-0">
<div class="flex items-center gap-3">
<span class="w-8 text-sm font-semibold text-[#b5bac1]">#{{ $loop->iteration }}</span>
<img src="{{ $emoteImageSrc($emote->image) }}" alt="{{ $emote->emote_name }}" class="object-cover w-9 h-9 border rounded border-[#3f4147]" />
<span class="font-medium text-[#dbdee1]">{{ $emote->emote_name }}</span>
</div>
<span class="text-sm font-medium text-[#b5bac1]">{{ $emote->total_usage }}</span>
</div>
@empty
<p class="px-4 py-3 text-sm text-[#b5bac1]">No static emote usage found.</p>
@endforelse
</div>
</div>
<div class="space-y-3">
<h3 class="text-xl font-semibold text-white">Top 10 Animated Emotes</h3>
<div class="overflow-hidden border rounded-lg bg-[#2b2d31] border-[#3f4147]">
@forelse (($stats['top_animated'] ?? collect()) as $emote)
<div class="flex items-center justify-between px-4 py-3.5 border-b border-[#3f4147] last:border-b-0">
<div class="flex items-center gap-3">
<span class="w-8 text-sm font-semibold text-[#b5bac1]">#{{ $loop->iteration }}</span>
<img src="{{ $emoteImageSrc($emote->image) }}" alt="{{ $emote->emote_name }}" class="object-cover w-9 h-9 border rounded border-[#3f4147]" />
<span class="font-medium text-[#dbdee1]">{{ $emote->emote_name }}</span>
</div>
<span class="text-sm font-medium text-[#b5bac1]">{{ $emote->total_usage }}</span>
</div>
@empty
<p class="px-4 py-3 text-sm text-[#b5bac1]">No animated emote usage found.</p>
@endforelse
</div>
</div>
<div class="space-y-3">
<h3 class="text-xl font-semibold text-white">Top 10 Unicode Emotes</h3>
<div class="overflow-hidden border rounded-lg bg-[#2b2d31] border-[#3f4147]">
@forelse (($stats['top_unicode'] ?? collect()) as $emote)
<div class="flex items-center justify-between px-4 py-3.5 border-b border-[#3f4147] last:border-b-0">
<div class="flex items-center gap-3">
<span class="w-8 text-sm font-semibold text-[#b5bac1]">#{{ $loop->iteration }}</span>
<span class="font-medium text-[#dbdee1]">{{ $emote->emote_name }}</span>
</div>
<span class="text-sm font-medium text-[#b5bac1]">{{ $emote->total_usage }}</span>
</div>
@empty
<p class="px-4 py-3 text-sm text-[#b5bac1]">No unicode emote usage found.</p>
@endforelse
</div>
</div>
</div>
<script id="homepage-chart-json" type="application/json">@json($chartPayload)</script>
@once
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
window.hermesCharts = window.hermesCharts || {};
function hermesDestroyChart(key) {
if (window.hermesCharts[key]) {
window.hermesCharts[key].destroy();
delete window.hermesCharts[key];
}
}
function hermesGetChartPayload() {
const statsElement = document.getElementById('homepage-chart-json');
if (!statsElement) {
return null;
}
try {
return JSON.parse(statsElement.textContent || '{}');
} catch (error) {
return null;
}
}
function hermesRenderOverTimeChart(payloadOverride) {
if (typeof Chart === 'undefined') {
return;
}
const payload = payloadOverride || hermesGetChartPayload();
if (!payload) {
return;
}
const usageOverTimeCanvas = document.getElementById('emoji-usage-over-time-chart');
const usageOverTime = payload.usage_over_time || { labels: [], values: [] };
if (usageOverTimeCanvas) {
hermesDestroyChart('usageOverTime');
window.hermesCharts.usageOverTime = new Chart(usageOverTimeCanvas, {
type: 'line',
data: {
labels: usageOverTime.labels || [],
datasets: [{
label: 'Usage',
data: usageOverTime.values || [],
tension: 0.3,
fill: false,
borderColor: '#5865f2',
pointBackgroundColor: '#5865f2',
pointBorderColor: '#5865f2'
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
labels: {
color: '#dbdee1'
}
}
},
scales: {
x: {
ticks: {
color: '#b5bac1'
},
grid: {
color: '#3f4147'
}
},
y: {
beginAtZero: true,
ticks: {
precision: 0,
color: '#b5bac1'
},
grid: {
color: '#3f4147'
}
}
}
}
});
}
}
document.addEventListener('DOMContentLoaded', function () {
hermesRenderOverTimeChart();
});
</script>
@endonce
</div>
</x-layouts.app>
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $title ?? config('app.name') }}</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen text-[#dbdee1] bg-[#313338]">
{{ $slot }}
</body>
</html>