Files
ghassanandClaude Opus 4.7 05ccb45fe4 feat(admin): strip HTML in video list + First/Last pagination
- Admin video list: description was stored as sanitized rich HTML, so
  the admin's "{{ Str::limit(description, 55) }}" row printed literal
  <p>…</p> tags in the preview. Pre-strip the HTML, collapse
  whitespace, and surface the full stripped text in a title=""
  tooltip. The visible title column now also carries title="" so the
  admin can hover an ellipsized name to read it in full.
- Shared pagination partial (used by admin videos + users + feed
  pagers) now ships First + Last buttons. The icons use the double-
  chevron variants and the aria-label makes the destination explicit.
  Buttons disable cleanly on page 1 and the last page.
- Audit-logs pager has the same four buttons: First, Prev, N…N, Next,
  Last. Each disabled state carries a title tooltip so the admin
  knows what each stub does without hovering through trial and error.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:01:04 +03:00

402 lines
17 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@extends('admin.layout')
@section('title', 'Audit Logs')
@section('content')
<style>
.al-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 20px; flex-wrap: wrap; gap: 12px;
}
.al-title { font-size: 22px; font-weight: 600; display: flex; align-items: center; gap: 10px; }
.al-title i { color: var(--brand); }
/* Filter bar */
.al-filters {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 20px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
align-items: end;
}
.al-filter-group label {
display: block; font-size: 11px; font-weight: 700;
letter-spacing: .7px; text-transform: uppercase;
color: var(--text-muted); margin-bottom: 6px;
}
.al-filter-input {
width: 100%; background: var(--bg-body); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 12px; color: var(--text);
font-size: 13px; line-height: 1;
}
.al-filter-input:focus { outline: none; border-color: var(--brand); }
.al-filter-input option { background: #1a1a1a; }
.al-filter-btn {
display: flex; gap: 8px;
}
.al-btn {
padding: 9px 16px; border-radius: 8px; font-size: 13px;
font-weight: 600; cursor: pointer; border: none; white-space: nowrap;
}
.al-btn-primary { background: var(--brand); color: #fff; }
.al-btn-ghost { background: var(--border-light); color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 6px; }
.al-btn-ghost:hover { background: var(--border); color: var(--text); }
/* Stats strip */
.al-stats {
display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap;
}
.al-stat {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 10px; padding: 10px 18px; font-size: 13px;
display: flex; align-items: center; gap: 8px;
}
.al-stat strong { font-size: 18px; font-weight: 700; }
.al-stat-label { color: var(--text-muted); font-size: 12px; }
/* Table */
.al-table-wrap {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.al-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.al-table thead tr {
background: rgba(255,255,255,.03);
border-bottom: 1px solid var(--border);
}
.al-table th {
padding: 11px 14px; text-align: left;
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: .6px; color: var(--text-muted); white-space: nowrap;
}
.al-table td { padding: 10px 14px; border-bottom: 1px solid rgba(255,255,255,.04); vertical-align: middle; }
.al-table tr:last-child td { border-bottom: none; }
.al-table tbody tr:hover { background: rgba(255,255,255,.02); }
/* Severity badges */
.al-badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 10px; border-radius: 20px;
font-size: 11px; font-weight: 700; white-space: nowrap;
}
.al-badge-danger { background: rgba(239,68,68,.15); color: #f87171; border: 1px solid rgba(239,68,68,.3); }
.al-badge-warning { background: rgba(251,191,36,.15); color: #fbbf24; border: 1px solid rgba(251,191,36,.3); }
.al-badge-success { background: rgba(34,197,94,.15); color: #4ade80; border: 1px solid rgba(34,197,94,.3); }
.al-badge-info { background: rgba(96,165,250,.15); color: #60a5fa; border: 1px solid rgba(96,165,250,.3); }
.al-badge-purple { background: rgba(167,139,250,.15);color: #a78bfa; border: 1px solid rgba(167,139,250,.3); }
.al-badge-orange { background: rgba(251,146,60,.15); color: #fb923c; border: 1px solid rgba(251,146,60,.3); }
.al-badge-muted { background: rgba(255,255,255,.06); color: var(--text-muted); border: 1px solid var(--border); }
.al-badge-default { background: rgba(255,255,255,.06); color: var(--text); border: 1px solid var(--border); }
/* Icon per severity */
.al-badge-danger i { color: #f87171; }
.al-badge-warning i { color: #fbbf24; }
.al-badge-success i { color: #4ade80; }
.al-badge-info i { color: #60a5fa; }
/* User cell */
.al-user { display: flex; align-items: center; gap: 8px; }
.al-user-avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.al-user-name { font-weight: 600; }
.al-user-id { font-size: 11px; color: var(--text-muted); }
/* IP cell */
.al-ip { font-family: monospace; font-size: 12px; color: var(--text-muted); }
.al-ip a { color: inherit; text-decoration: underline dotted; }
/* Subject cell */
.al-subject { max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Details expand */
.al-details-btn {
background: none; border: none; color: var(--text-muted);
cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 12px;
}
.al-details-btn:hover { background: var(--border-light); color: var(--text); }
.al-details-row td {
padding: 0 !important;
border-bottom: 1px solid rgba(255,255,255,.04) !important;
}
.al-details-inner {
padding: 10px 14px 14px 44px;
font-family: monospace; font-size: 12px;
color: var(--text-muted); white-space: pre-wrap;
background: rgba(0,0,0,.15);
}
/* Pagination */
.al-pagination {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; border-top: 1px solid var(--border);
font-size: 13px; color: var(--text-muted); flex-wrap: wrap; gap: 10px;
}
.al-page-links { display: flex; gap: 4px; }
.al-page-links a, .al-page-links span {
padding: 5px 10px; border-radius: 6px; font-size: 13px;
border: 1px solid var(--border); color: var(--text-muted); text-decoration: none;
}
.al-page-links a:hover { background: var(--border-light); color: var(--text); }
.al-page-links span.active { background: var(--brand); border-color: var(--brand); color: #fff; }
.al-page-links span.disabled { opacity: .4; cursor: not-allowed; }
/* Empty state */
.al-empty {
padding: 60px 20px; text-align: center; color: var(--text-muted);
}
.al-empty i { font-size: 48px; display: block; margin-bottom: 12px; }
</style>
<div class="al-header">
<div class="al-title">
<i class="bi bi-shield-check"></i> Audit Logs
</div>
<div style="font-size:13px;color:var(--text-muted);">
{{ $logs->total() }} events found
@if(request()->hasAny(['action','user','ip','subject','date_from','date_to']))
— <a href="{{ route('admin.audit') }}" style="color:var(--brand);">clear filters</a>
@endif
</div>
</div>
{{-- Filters --}}
<form method="GET" action="{{ route('admin.audit') }}">
<div class="al-filters">
<div class="al-filter-group">
<label>Action Type</label>
<select name="action" class="al-filter-input">
<option value="">All Actions</option>
@foreach($actionTypes as $type)
<option value="{{ $type }}" {{ request('action') === $type ? 'selected' : '' }}>
{{ ucwords(str_replace(['.','_'], ' ', $type)) }}
</option>
@endforeach
</select>
</div>
<div class="al-filter-group">
<label>User / Email</label>
<input type="text" name="user" class="al-filter-input" value="{{ request('user') }}" placeholder="Name or email…">
</div>
<div class="al-filter-group">
<label>IP Address</label>
<input type="text" name="ip" class="al-filter-input" value="{{ request('ip') }}" placeholder="e.g. 1.2.3.4">
</div>
<div class="al-filter-group">
<label>Subject</label>
<input type="text" name="subject" class="al-filter-input" value="{{ request('subject') }}" placeholder="Video title, username…">
</div>
<div class="al-filter-group">
<label>From Date</label>
<input type="date" name="date_from" class="al-filter-input" value="{{ request('date_from') }}">
</div>
<div class="al-filter-group">
<label>To Date</label>
<input type="date" name="date_to" class="al-filter-input" value="{{ request('date_to') }}">
</div>
<div class="al-filter-group al-filter-btn">
<button type="submit" class="al-btn al-btn-primary"><i class="bi bi-funnel-fill"></i> Filter</button>
<a href="{{ route('admin.audit') }}" class="al-btn al-btn-ghost"><i class="bi bi-x-lg"></i></a>
</div>
</div>
</form>
{{-- Table --}}
<div class="al-table-wrap">
@if($logs->isEmpty())
<div class="al-empty">
<i class="bi bi-shield-check"></i>
No audit events found.
</div>
@else
<table class="al-table" id="auditTable">
<thead>
<tr>
<th>Time</th>
<th>Action</th>
<th>User</th>
<th>Subject</th>
<th>IP Address</th>
<th>Device</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach($logs as $log)
@php
$severity = $log->severity;
$icon = match($severity) {
'danger' => 'bi-trash3-fill',
'warning' => 'bi-exclamation-triangle-fill',
'success' => 'bi-cloud-upload-fill',
'info' => 'bi-box-arrow-in-right',
'purple' => 'bi-shield-lock-fill',
'orange' => 'bi-person-badge-fill',
'muted' => 'bi-box-arrow-right',
default => 'bi-dot',
};
$ua = $log->user_agent ?? '';
$device = match(true) {
str_contains($ua, 'Mobile') || str_contains($ua, 'Android') => '📱 Mobile',
str_contains($ua, 'Tablet') || str_contains($ua, 'iPad') => '📟 Tablet',
default => '🖥️ Desktop',
};
$browser = match(true) {
str_contains($ua, 'Firefox') => 'Firefox',
str_contains($ua, 'Chrome') && !str_contains($ua, 'Chromium') && !str_contains($ua, 'Edg') => 'Chrome',
str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome') => 'Safari',
str_contains($ua, 'Edg') => 'Edge',
str_contains($ua, 'curl') => 'cURL',
default => 'Unknown',
};
@endphp
<tr>
<td style="white-space:nowrap;">
<div style="font-weight:600;font-size:13px;">{{ $log->created_at->format('d M Y') }}</div>
<div style="font-size:11px;color:var(--text-muted);">{{ $log->created_at->format('H:i:s') }}</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:1px;" title="{{ $log->created_at }}">{{ $log->created_at->diffForHumans() }}</div>
</td>
<td>
<span class="al-badge al-badge-{{ $severity }}">
<i class="bi {{ $icon }}"></i>
{{ $log->action_label }}
</span>
</td>
<td>
<div class="al-user">
@if($log->user)
<img src="{{ $log->user->avatar_url }}" alt="" class="al-user-avatar">
@else
<div class="al-user-avatar" style="background:var(--border);display:flex;align-items:center;justify-content:center;font-size:12px;">
<i class="bi bi-person" style="color:var(--text-muted);"></i>
</div>
@endif
<div>
<div class="al-user-name">
@if($log->user)
<a href="{{ route('admin.users.edit', $log->user) }}" style="color:inherit;text-decoration:none;" onmouseover="this.style.textDecoration='underline'" onmouseout="this.style.textDecoration='none'">
{{ $log->user_name ?? 'Unknown' }}
</a>
@else
{{ $log->user_name ?? ($log->action === 'user.login.failed' ? 'Guest' : 'Unknown') }}
@endif
</div>
@if($log->user_id)
<div class="al-user-id">#{{ $log->user_id }}</div>
@endif
</div>
</div>
</td>
<td>
@if($log->subject_label)
<div class="al-subject" title="{{ $log->subject_label }}">
@if($log->subject_type === 'Video' && $log->subject_id)
<a href="{{ route('videos.show', \App\Models\Video::encodeId((int)$log->subject_id)) }}" style="color:var(--brand);text-decoration:none;" target="_blank">
{{ $log->subject_label }}
</a>
@elseif($log->subject_type === 'User' && $log->subject_id)
<a href="{{ route('admin.users.edit', $log->subject_id) }}" style="color:var(--brand);text-decoration:none;">
{{ $log->subject_label }}
</a>
@else
{{ $log->subject_label }}
@endif
</div>
<div style="font-size:11px;color:var(--text-muted);">{{ $log->subject_type }}</div>
@else
<span style="color:var(--text-muted);">—</span>
@endif
</td>
<td>
<div class="al-ip">
@if($log->ip_address)
<a href="{{ route('admin.audit', ['ip' => $log->ip_address]) }}" title="Filter by this IP">
{{ $log->ip_address }}
</a>
@else
<span>—</span>
@endif
</div>
</td>
<td style="font-size:12px;color:var(--text-muted);white-space:nowrap;">
{{ $device }} · {{ $browser }}
</td>
<td>
@if($log->details)
<button class="al-details-btn" onclick="toggleDetails({{ $log->id }})" title="View details">
<i class="bi bi-code-slash"></i>
</button>
@endif
</td>
</tr>
@if($log->details)
<tr class="al-details-row" id="details-{{ $log->id }}" style="display:none;">
<td colspan="7">
<div class="al-details-inner">{{ json_encode($log->details, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</div>
</td>
</tr>
@endif
@endforeach
</tbody>
</table>
{{-- Pagination --}}
@if($logs->hasPages())
<div class="al-pagination">
<div>
Showing {{ $logs->firstItem() }}–{{ $logs->lastItem() }} of {{ $logs->total() }} events
</div>
<div class="al-page-links">
{{-- First --}}
@if($logs->onFirstPage())
<span class="disabled" title="First page"><i class="bi bi-chevron-double-left" aria-hidden="true"></i></span>
@else
<a href="{{ $logs->url(1) }}" aria-label="First page" title="First page"><i class="bi bi-chevron-double-left" aria-hidden="true"></i></a>
@endif
{{-- Prev --}}
@if($logs->onFirstPage())
<span class="disabled" title="Previous page"><i class="bi bi-chevron-left" aria-hidden="true"></i></span>
@else
<a href="{{ $logs->previousPageUrl() }}" aria-label="Previous page" title="Previous page"><i class="bi bi-chevron-left" aria-hidden="true"></i></a>
@endif
@foreach($logs->getUrlRange(max(1, $logs->currentPage()-2), min($logs->lastPage(), $logs->currentPage()+2)) as $page => $url)
@if($page == $logs->currentPage())
<span class="active" aria-current="page">{{ $page }}</span>
@else
<a href="{{ $url }}">{{ $page }}</a>
@endif
@endforeach
{{-- Next --}}
@if($logs->hasMorePages())
<a href="{{ $logs->nextPageUrl() }}" aria-label="Next page" title="Next page"><i class="bi bi-chevron-right" aria-hidden="true"></i></a>
@else
<span class="disabled" title="Next page"><i class="bi bi-chevron-right" aria-hidden="true"></i></span>
@endif
{{-- Last --}}
@if($logs->currentPage() < $logs->lastPage())
<a href="{{ $logs->url($logs->lastPage()) }}" aria-label="Last page" title="Last page ({{ $logs->lastPage() }})"><i class="bi bi-chevron-double-right" aria-hidden="true"></i></a>
@else
<span class="disabled" title="Last page"><i class="bi bi-chevron-double-right" aria-hidden="true"></i></span>
@endif
</div>
</div>
@endif
@endif
</div>
<script>
function toggleDetails(id) {
const row = document.getElementById('details-' + id);
if (row) row.style.display = row.style.display === 'none' ? 'table-row' : 'none';
}
</script>
@endsection