Featured artworks thumbnails

This commit is contained in:
2026-05-06 19:11:31 +02:00
parent 82f2b1f660
commit 0c5dde9b22
36 changed files with 55994 additions and 30 deletions

View File

@@ -0,0 +1,290 @@
@extends('layouts.nova')
@php
$initialPayload = [
'summary' => $summary,
'visitors' => $visitors,
'active_pages' => $activePages,
'generated_at' => $generatedAt,
];
@endphp
@push('head')
<style>
body.page-moderation main { padding-top: 4rem; }
</style>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.body.classList.add('page-moderation')
})
</script>
@endpush
@section('content')
<section class="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6 sm:px-6 lg:px-8 text-zinc-100">
<div class="flex flex-col gap-4 rounded-3xl border border-white/10 bg-slate-950/70 p-6 shadow-2xl shadow-slate-950/40">
<div class="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<div class="text-xs font-semibold uppercase tracking-[0.24em] text-cyan-300/80">Moderation Traffic</div>
<h1 class="mt-2 text-3xl font-semibold text-white">Online Visitors</h1>
<p class="mt-2 max-w-3xl text-sm text-zinc-300">Live view of logged users, guests, crawlers, AI bots, and suspicious traffic.</p>
</div>
<div class="flex flex-wrap items-center gap-3 text-sm text-zinc-400">
<a href="{{ url('/moderation') }}" class="inline-flex items-center rounded-full border border-white/10 px-4 py-2 text-zinc-200 transition hover:border-cyan-300/40 hover:text-cyan-200">Back to moderation</a>
<span id="online-generated-at" class="rounded-full border border-white/10 bg-white/5 px-4 py-2">Updated {{ $generatedAt }}</span>
</div>
</div>
<div id="online-summary" class="grid gap-3 sm:grid-cols-2 xl:grid-cols-7"></div>
</div>
<div class="grid gap-6 xl:grid-cols-[1.7fr_1fr]">
<div class="rounded-3xl border border-white/10 bg-slate-950/70 p-6 shadow-2xl shadow-slate-950/30">
<div class="flex flex-col gap-4 border-b border-white/10 pb-5 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-lg font-semibold text-white">Visitors</h2>
<p class="mt-1 text-sm text-zinc-400">Filter the live table without leaving the page.</p>
</div>
<div id="visitor-filters" class="flex flex-wrap gap-2"></div>
</div>
<div class="mt-5 overflow-x-auto">
<table class="min-w-full divide-y divide-white/10 text-sm">
<thead class="text-left text-xs uppercase tracking-[0.18em] text-zinc-500">
<tr>
<th class="px-3 py-3 font-medium">Type</th>
<th class="px-3 py-3 font-medium">User</th>
<th class="px-3 py-3 font-medium">IP</th>
<th class="px-3 py-3 font-medium">Bot / Browser</th>
<th class="px-3 py-3 font-medium">Current URL</th>
<th class="px-3 py-3 font-medium">Referer</th>
<th class="px-3 py-3 font-medium">First Seen</th>
<th class="px-3 py-3 font-medium">Last Seen</th>
<th class="px-3 py-3 font-medium">Hits</th>
</tr>
</thead>
<tbody id="online-visitors-table" class="divide-y divide-white/5 text-zinc-200"></tbody>
</table>
</div>
</div>
<aside class="rounded-3xl border border-white/10 bg-slate-950/70 p-6 shadow-2xl shadow-slate-950/30">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-white">Active Pages</h2>
<p class="mt-1 text-sm text-zinc-400">Current URLs with live visitor counts.</p>
</div>
</div>
<div id="online-active-pages" class="mt-5 space-y-3"></div>
</aside>
</div>
</section>
<script>
(() => {
const dataUrl = @json($dataUrl);
const initialState = @json($initialPayload);
const summaryContainer = document.getElementById('online-summary');
const filtersContainer = document.getElementById('visitor-filters');
const tableBody = document.getElementById('online-visitors-table');
const activePagesContainer = document.getElementById('online-active-pages');
const generatedAtContainer = document.getElementById('online-generated-at');
const dateFormatter = new Intl.DateTimeFormat(undefined, {
year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'
});
const summaryCards = [
{ key: 'total', label: 'Online now' },
{ key: 'logged', label: 'Logged users' },
{ key: 'guests', label: 'Guests' },
{ key: 'bots', label: 'Bots' },
{ key: 'search_bots', label: 'Search bots' },
{ key: 'ai_bots', label: 'AI bots' },
{ key: 'suspicious_bots', label: 'Suspicious' },
];
const filterDefinitions = [
{ key: 'all', label: 'All', matches: () => true },
{ key: 'logged', label: 'Logged', matches: (visitor) => visitor.type === 'human_logged' },
{ key: 'guests', label: 'Guests', matches: (visitor) => visitor.type === 'human_guest' },
{ key: 'bots', label: 'Bots', matches: (visitor) => String(visitor.type || '').endsWith('_bot') },
{ key: 'search_bot', label: 'Search bots', matches: (visitor) => visitor.type === 'search_bot' },
{ key: 'ai_bot', label: 'AI bots', matches: (visitor) => visitor.type === 'ai_bot' },
{ key: 'social_bot', label: 'Social bots', matches: (visitor) => visitor.type === 'social_bot' },
{ key: 'seo_bot', label: 'SEO bots', matches: (visitor) => visitor.type === 'seo_bot' },
{ key: 'suspicious_bot', label: 'Suspicious', matches: (visitor) => visitor.type === 'suspicious_bot' },
];
const typeLabels = {
human_logged: 'Logged',
human_guest: 'Guest',
search_bot: 'Search Bot',
ai_bot: 'AI Bot',
social_bot: 'Social Bot',
seo_bot: 'SEO Bot',
monitoring_bot: 'Monitoring Bot',
suspicious_bot: 'Suspicious',
unknown_bot: 'Unknown Bot',
};
const typeClasses = {
human_logged: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-200',
human_guest: 'border-sky-400/30 bg-sky-400/10 text-sky-200',
search_bot: 'border-indigo-400/30 bg-indigo-400/10 text-indigo-200',
ai_bot: 'border-fuchsia-400/30 bg-fuchsia-400/10 text-fuchsia-200',
social_bot: 'border-amber-400/30 bg-amber-400/10 text-amber-200',
seo_bot: 'border-orange-400/30 bg-orange-400/10 text-orange-200',
monitoring_bot: 'border-teal-400/30 bg-teal-400/10 text-teal-200',
suspicious_bot: 'border-rose-400/30 bg-rose-400/10 text-rose-200',
unknown_bot: 'border-zinc-400/30 bg-zinc-400/10 text-zinc-200',
};
let activeFilter = 'all';
let state = initialState;
function formatDate(value) {
if (!value) {
return '—';
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : dateFormatter.format(parsed);
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function renderSummary() {
summaryContainer.innerHTML = summaryCards.map((card) => `
<article class="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div class="text-xs uppercase tracking-[0.18em] text-zinc-500">${card.label}</div>
<div class="mt-3 text-3xl font-semibold text-white">${state.summary?.[card.key] ?? 0}</div>
</article>
`).join('');
}
function renderFilters() {
filtersContainer.innerHTML = filterDefinitions.map((filter) => {
const isActive = filter.key === activeFilter;
return `<button type="button" data-filter="${filter.key}" class="rounded-full border px-3 py-1.5 text-sm transition ${isActive ? 'border-cyan-300/50 bg-cyan-300/10 text-cyan-100' : 'border-white/10 bg-white/[0.03] text-zinc-300 hover:border-white/20 hover:text-white'}">${filter.label}</button>`;
}).join('');
filtersContainer.querySelectorAll('[data-filter]').forEach((button) => {
button.addEventListener('click', () => {
activeFilter = button.getAttribute('data-filter') || 'all';
renderFilters();
renderVisitors();
});
});
}
function filteredVisitors() {
const currentFilter = filterDefinitions.find((filter) => filter.key === activeFilter) || filterDefinitions[0];
return (state.visitors || []).filter(currentFilter.matches);
}
function renderVisitors() {
const visitors = filteredVisitors();
if (visitors.length === 0) {
tableBody.innerHTML = '<tr><td colspan="9" class="px-3 py-8 text-center text-zinc-500">No visitors match the current filter.</td></tr>';
return;
}
tableBody.innerHTML = visitors.map((visitor) => {
const type = String(visitor.type || 'unknown_bot');
const badgeClass = typeClasses[type] || typeClasses.unknown_bot;
const badgeLabel = typeLabels[type] || 'Unknown';
const agentLabel = visitor.bot_family || visitor.browser || 'Unknown';
const userLabel = visitor.user_name || (type === 'human_guest' ? 'Guest visitor' : 'Anonymous');
const currentUrl = visitor.current_url || '/';
const referer = visitor.referer || '—';
return `
<tr class="align-top">
<td class="px-3 py-4"><span class="inline-flex rounded-full border px-2.5 py-1 text-xs font-semibold ${badgeClass}">${badgeLabel}</span></td>
<td class="px-3 py-4">
<div class="font-medium text-white">${escapeHtml(userLabel)}</div>
<div class="mt-1 text-xs text-zinc-500">${visitor.user_id ? `User #${escapeHtml(visitor.user_id)}` : 'No account'}</div>
</td>
<td class="px-3 py-4 font-mono text-xs text-zinc-300">${escapeHtml(visitor.ip_masked || 'unknown')}</td>
<td class="px-3 py-4">
<div class="font-medium text-white">${escapeHtml(agentLabel)}</div>
<div class="mt-1 text-xs text-zinc-500">${escapeHtml(visitor.browser || 'Unknown')} · ${escapeHtml(visitor.platform || 'Unknown')}</div>
</td>
<td class="px-3 py-4">
<a href="${escapeHtml(currentUrl)}" class="break-all text-cyan-200 hover:text-cyan-100 hover:underline">${escapeHtml(currentUrl)}</a>
<div class="mt-1 text-xs text-zinc-500">${escapeHtml(visitor.route_name || 'No route name')}</div>
</td>
<td class="px-3 py-4 break-all text-zinc-400">${escapeHtml(referer)}</td>
<td class="px-3 py-4 text-zinc-300">${escapeHtml(formatDate(visitor.first_seen_at))}</td>
<td class="px-3 py-4 text-zinc-300">${escapeHtml(formatDate(visitor.last_seen_at))}</td>
<td class="px-3 py-4 text-white">${escapeHtml(visitor.hits || 0)}</td>
</tr>
`;
}).join('');
}
function renderActivePages() {
const pages = state.active_pages || [];
if (pages.length === 0) {
activePagesContainer.innerHTML = '<div class="rounded-2xl border border-dashed border-white/10 px-4 py-6 text-sm text-zinc-500">No active public pages recorded.</div>';
return;
}
activePagesContainer.innerHTML = pages.map((page) => `
<div class="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<div class="flex items-start justify-between gap-3">
<a href="${escapeHtml(page.url)}" class="break-all text-sm font-medium text-cyan-200 hover:text-cyan-100 hover:underline">${escapeHtml(page.url)}</a>
<span class="rounded-full border border-white/10 bg-white/[0.06] px-2.5 py-1 text-xs text-zinc-300">${escapeHtml(page.visitors)} online</span>
</div>
</div>
`).join('');
}
function renderGeneratedAt() {
generatedAtContainer.textContent = `Updated ${formatDate(state.generated_at)}`;
}
async function loadOnlineVisitors() {
try {
const response = await fetch(dataUrl, {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
});
if (!response.ok) {
return;
}
state = await response.json();
renderAll();
} catch (_error) {
// Keep the last rendered snapshot if polling fails.
}
}
function renderAll() {
renderSummary();
renderFilters();
renderVisitors();
renderActivePages();
renderGeneratedAt();
}
renderAll();
setInterval(loadOnlineVisitors, 10000);
})();
</script>
@endsection