## Analytics — Live Activity Feed - Dedicated /stats/live standalone page (mirrors /stats/logs pattern) - Consistent 3-tab navigation across Statistics / Live Feed / Visit Logs - checkForUpdates() with skipRender(): O(1) MAX(id) poll — ~100-byte response on no-change - latestVisitId sentinel (-1) prevents unnecessary first-poll re-render - Cache key versioned by latestVisitId — fixes bug where concurrent users could permanently miss a visit due to stale thundering-herd cache - 3-second thundering-herd cache (key: url_id + date + filters + latestVisitId) - Country flags via flagcdn.com (precomputed in PHP, Alpine.js CSP-safe onerror) - time_ago precomputed in PHP — no Carbon::parse() double-instantiation in Blade - SELECT only 12 needed columns instead of SELECT *; limit 25 rows - wire:poll.5s.visible — polling stops when widget is off-screen ## Link Organization - Folders: one folder per link, clickable → filtered link list, link count badge - Tags: up to 5 tags per link, clickable → filtered link list, link count badge - Archiving: soft-archive links instead of permanent deletion; restorable ## REST API - Per-key API scopes: links:read-only (GET) / links:read-write (full CRUD) - Per-key rate limiting: individual requests/minute per API key ## Translations - Added stats_live_feed_poll_interval (EN: 'Updates every 5s', PL: 'Aktualizacja co 5s') ## Docs - README.md: corrected API scopes/rate-limiting docs (features were already implemented) - README.md: added Folders, Tags, Archiving to features list - README.md: added v5.0.0 changelog entry
140 lines
7.1 KiB
PHP
140 lines
7.1 KiB
PHP
<x-filament-panels::page>
|
|
<x-filament::tabs class="mb-6">
|
|
<x-filament::tabs.item
|
|
wire:click="$set('activeTab', 'statistics')"
|
|
:active="$activeTab === 'statistics'"
|
|
icon="heroicon-m-presentation-chart-line"
|
|
>
|
|
{{ __('filament-short-url::default.stats_tab_statistics') }}
|
|
</x-filament::tabs.item>
|
|
|
|
<x-filament::tabs.item
|
|
tag="a"
|
|
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats.live', ['record' => $record]) }}"
|
|
icon="heroicon-m-bolt"
|
|
>
|
|
{{ __('filament-short-url::default.stats_tab_live_feed') }}
|
|
</x-filament::tabs.item>
|
|
|
|
<x-filament::tabs.item
|
|
tag="a"
|
|
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats.logs', ['record' => $record]) }}"
|
|
icon="heroicon-m-list-bullet"
|
|
>
|
|
{{ __('filament-short-url::default.stats_tab_visit_logs') }}
|
|
</x-filament::tabs.item>
|
|
</x-filament::tabs>
|
|
|
|
@php
|
|
$dateFrom = $this->filterData['date_from'] ?? null;
|
|
$dateTo = $this->filterData['date_to'] ?? null;
|
|
$isCustom = ($this->filterData['preset'] ?? '') === 'custom';
|
|
$activeFilters = $this->activeFilters ?? [];
|
|
$filtersHash = md5(json_encode($activeFilters));
|
|
@endphp
|
|
|
|
<div class="space-y-6">
|
|
<div class="flex justify-end items-center">
|
|
<div class="w-full {{ $isCustom ? 'max-w-xl' : 'max-w-[200px]' }}">
|
|
<form>
|
|
{{ $this->form }}
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
@if (! empty($activeFilters))
|
|
<div class="flex flex-wrap items-center gap-2 p-3 bg-gray-50/50 dark:bg-white/5 border border-gray-100 dark:border-white/10 rounded-xl">
|
|
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mr-1">Active Filters:</span>
|
|
@foreach ($activeFilters as $key => $value)
|
|
@php
|
|
$displayValue = $value;
|
|
if ($key === 'country_code') {
|
|
$translatedCountry = __('filament-short-url::countries.' . strtoupper($value));
|
|
if ($translatedCountry && $translatedCountry !== 'filament-short-url::countries.' . strtoupper($value)) {
|
|
$displayValue = $translatedCountry;
|
|
}
|
|
}
|
|
@endphp
|
|
<div class="flex items-center gap-1.5 px-3 py-1 bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-100 dark:border-indigo-900/60 rounded-full text-xs font-semibold text-indigo-700 dark:text-indigo-300">
|
|
<span class="opacity-75 uppercase text-[10px] tracking-wider">{{ $key === 'country_code' ? 'country' : str_replace('_', ' ', $key) }}:</span>
|
|
<span>{{ $displayValue }}</span>
|
|
<button type="button"
|
|
wire:click="clearStatsFilter('{{ $key }}')"
|
|
class="hover:text-indigo-900 dark:hover:text-indigo-100 transition-colors focus:outline-none ml-1">
|
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
@endforeach
|
|
<button type="button"
|
|
wire:click="clearAllStatsFilters"
|
|
class="text-xs font-medium text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400 transition-colors focus:outline-none ml-auto px-2.5 py-1 rounded-md hover:bg-gray-100 dark:hover:bg-white/5">
|
|
Clear all filters
|
|
</button>
|
|
</div>
|
|
@endif
|
|
|
|
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlStatsOverview::class, [
|
|
'record' => $record,
|
|
'dateFrom' => $dateFrom,
|
|
'dateTo' => $dateTo,
|
|
'filters' => $activeFilters,
|
|
], key('stats-overview-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
|
|
|
|
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, [
|
|
'record' => $record,
|
|
'dateFrom' => $dateFrom,
|
|
'dateTo' => $dateTo,
|
|
'filters' => $activeFilters,
|
|
], key('stats-chart-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
|
|
|
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
|
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGeoBreakdownWidget::class, [
|
|
'record' => $record,
|
|
'dateFrom' => $dateFrom,
|
|
'dateTo' => $dateTo,
|
|
'filters' => $activeFilters,
|
|
], key('stats-geo-breakdown-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
|
|
|
|
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlDeviceBreakdownWidget::class, [
|
|
'record' => $record,
|
|
'dateFrom' => $dateFrom,
|
|
'dateTo' => $dateTo,
|
|
'filters' => $activeFilters,
|
|
], key('stats-device-breakdown-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
|
|
</div>
|
|
|
|
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlWorldMapWidget::class, [
|
|
'record' => $record,
|
|
'dateFrom' => $dateFrom,
|
|
'dateTo' => $dateTo,
|
|
'filters' => $activeFilters,
|
|
], key('stats-world-map-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
|
|
|
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
|
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlSourcesBreakdownWidget::class, [
|
|
'record' => $record,
|
|
'dateFrom' => $dateFrom,
|
|
'dateTo' => $dateTo,
|
|
'filters' => $activeFilters,
|
|
], key('stats-sources-breakdown-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
|
|
|
|
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlSecurityBreakdownWidget::class, [
|
|
'record' => $record,
|
|
'dateFrom' => $dateFrom,
|
|
'dateTo' => $dateTo,
|
|
'filters' => $activeFilters,
|
|
], key('stats-security-breakdown-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
|
|
</div>
|
|
|
|
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVariantsWidget::class, [
|
|
'record' => $record,
|
|
'dateFrom' => $dateFrom,
|
|
'dateTo' => $dateTo,
|
|
'filters' => $activeFilters,
|
|
], key('stats-variants-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
|
|
</div>
|
|
</x-filament-panels::page>
|
|
|