Files
filament-short-url/resources/views/widgets/live-feed.blade.php

199 lines
12 KiB
PHP

<x-filament-widgets::widget>
{{--
Server-Sent Events push visit cursor updates to the browser.
onStreamUpdate() advances latestVisitId and triggers a render only when needed.
--}}
<div
x-data="{
source: null,
cursor: @js(max($latestVisitId, 0)),
reconnectTimer: null,
connect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.source?.close();
const url = @js(route('short-url.live-feed.stream', ['shortUrl' => $record->id]))
+ '&cursor=' + encodeURIComponent(this.cursor);
this.source = new EventSource(url);
this.source.addEventListener('update', (event) => {
const payload = JSON.parse(event.data);
if (payload.latest_id !== undefined) {
this.cursor = payload.latest_id;
$wire.onStreamUpdate(payload.latest_id);
}
});
this.source.onerror = () => {
this.source?.close();
this.reconnectTimer = setTimeout(() => this.connect(), 2000);
};
},
init() {
this.connect();
},
destroy() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
this.source?.close();
},
}"
x-init="init()"
@disconnect.window="destroy()"
class="fi-section rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900 overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-200 dark:border-white/10 bg-gray-50/50 dark:bg-white/5 px-6 py-4">
<div class="flex items-center gap-2">
<span class="flex h-2 w-2 relative">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
</span>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ __('filament-short-url::default.stats_tab_live_feed') }}
</h3>
</div>
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono">
@if ($usesRedisPush ?? false)
{{ __('filament-short-url::default.stats_live_feed_mode_redis') }}
@else
{{ __('filament-short-url::default.stats_live_feed_mode_poll') }}
@endif
</span>
</div>
<!-- Body -->
<div class="p-6 relative min-h-[300px]">
<!-- Spinner shown only during re-renders that actually happen -->
<div wire:loading class="absolute inset-0 bg-white/50 dark:bg-gray-900/50 backdrop-blur-[1px] z-10 transition-opacity">
<div class="flex items-center justify-center h-full w-full">
<x-filament::loading-indicator class="h-7 w-7 text-gray-900 dark:text-white" />
</div>
</div>
<div class="space-y-4" wire:loading.class="opacity-40 pointer-events-none transition-opacity">
@forelse ($visits as $visit)
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 p-4 border border-gray-100 dark:border-white/5 rounded-xl hover:bg-gray-50/50 dark:hover:bg-white/5 transition-all">
<!-- Visitor & Location Info -->
<div class="flex items-start gap-3 min-w-0 flex-1">
{{-- Flag from flagcdn.com. Alpine handles broken image gracefully without
inline onerror JS (which can violate CSP nonce policies). --}}
<div class="mt-0.5 shrink-0 w-6 flex items-center" title="{{ $visit['country'] }}">
@if ($visit['flag_url'])
<span x-data="{ error: false }">
<img x-show="!error"
x-on:error="error = true"
src="{{ $visit['flag_url'] }}"
width="20"
height="15"
alt="{{ $visit['country_code'] }}"
class="rounded-[2px] object-cover shadow-sm">
<span x-show="error" class="text-base leading-none">🌐</span>
</span>
@else
<span class="text-base leading-none">🌐</span>
@endif
</div>
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<button type="button"
x-on:click="$wire.dispatch('set-stats-filter', { key: 'country_code', value: '{{ $visit['country_code'] }}' })"
class="text-sm font-semibold text-gray-950 dark:text-white hover:underline truncate">
{{ $visit['city'] ?: 'Unknown City' }}, {{ $visit['country'] ?: 'Unknown Country' }}
</button>
@if ($visit['is_qr_scan'])
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-purple-50 text-purple-700 dark:bg-purple-950/40 dark:text-purple-300 border border-purple-100 dark:border-purple-900/40">
<x-filament::icon icon="heroicon-m-qr-code" class="w-3.5 h-3.5" />
QR Scan
</span>
@endif
@if ($visit['selected_variant'])
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300 border border-amber-100 dark:border-amber-900/40">
Variant: {{ $visit['selected_variant'] }}
</span>
@endif
</div>
<div class="text-xs text-gray-400 dark:text-gray-500 mt-1 flex items-center gap-2">
{{-- time_ago precomputed in PHP no Carbon::parse() per-row in Blade --}}
<span>{{ $visit['time_ago'] }}</span>
<span></span>
<span class="font-mono text-[11px]">{{ $visit['ip_address'] ?: '—' }}</span>
</div>
</div>
</div>
<!-- Referrer -->
<div class="flex items-center gap-2 min-w-[150px] max-w-[220px]">
<x-filament::icon icon="heroicon-m-link" class="w-4 h-4 text-gray-400 dark:text-gray-500 shrink-0" />
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 truncate"
title="{{ $visit['referer_url'] }}">
{{ $visit['referer_host'] ?: __('filament-short-url::default.stats_referer_direct') }}
</span>
</div>
<!-- Device / Browser / OS filters -->
<div class="flex items-center gap-2 shrink-0">
<!-- Device -->
<button type="button"
x-on:click="$wire.dispatch('set-stats-filter', { key: 'device_type', value: '{{ $visit['device_type'] }}' })"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
title="Filter by device: {{ $visit['device_type'] }}">
@if (strtolower($visit['device_type'] ?? '') === 'desktop')
<x-filament::icon icon="heroicon-m-computer-desktop" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
@elseif (strtolower($visit['device_type'] ?? '') === 'mobile')
<x-filament::icon icon="heroicon-m-device-phone-mobile" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
@elseif (strtolower($visit['device_type'] ?? '') === 'tablet')
<x-filament::icon icon="heroicon-m-device-tablet" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
@else
<x-filament::icon icon="heroicon-m-question-mark-circle" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
@endif
<span class="capitalize">{{ $visit['device_type'] ?: 'Unknown' }}</span>
</button>
<!-- Browser -->
<button type="button"
x-on:click="$wire.dispatch('set-stats-filter', { key: 'browser', value: '{{ $visit['browser'] }}' })"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
title="Filter by browser: {{ $visit['browser'] }}">
<x-filament::icon icon="heroicon-m-globe-alt" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
<span>{{ $visit['browser'] ?: 'Browser' }}</span>
</button>
<!-- OS -->
<button type="button"
x-on:click="$wire.dispatch('set-stats-filter', { key: 'operating_system', value: '{{ $visit['operating_system'] }}' })"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
title="Filter by OS: {{ $visit['operating_system'] }}">
<x-filament::icon icon="heroicon-m-cpu-chip" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
<span>{{ $visit['operating_system'] ?: 'OS' }}</span>
</button>
</div>
</div>
@empty
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-chat-bubble-left-right" class="h-6 w-6" />
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_live_feed_empty') }}
</p>
</div>
@endforelse
</div>
</div>
</div>
</x-filament-widgets::widget>