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

378 lines
19 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', 'Videos')
@php
$adminNeedsOtp = auth()->user()->two_factor_enabled &&
(! session('admin_2fa_verified_at') || now()->timestamp - session('admin_2fa_verified_at') >= 1800);
@endphp
@section('content')
{{-- ── Page header ──────────────────────────────────────────────────── --}}
<div class="adm-page-header">
<h1 class="adm-page-title"><i class="bi bi-play-circle"></i> Videos</h1>
</div>
{{-- ── Alerts ───────────────────────────────────────────────────────── --}}
@if(session('success'))
<div class="adm-alert adm-alert-success">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
@if(session('error'))
<div class="adm-alert adm-alert-error">
<i class="bi bi-exclamation-circle-fill"></i>
<span>{{ session('error') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
{{-- ── Filter card ──────────────────────────────────────────────────── --}}
<div class="adm-card">
<div class="adm-card-body" style="padding:16px 20px;">
<form method="GET" action="{{ route('admin.videos') }}" class="adm-filter-form">
<div class="adm-filter-search">
<i class="bi bi-search"></i>
<input type="text" name="search" class="adm-input"
placeholder="Search title or description…"
value="{{ request('search') }}" autocomplete="off">
</div>
<select name="status" class="adm-select">
<option value="">All Status</option>
<option value="ready" {{ request('status') === 'ready' ? 'selected' : '' }}>Ready</option>
<option value="processing" {{ request('status') === 'processing' ? 'selected' : '' }}>Processing</option>
<option value="pending" {{ request('status') === 'pending' ? 'selected' : '' }}>Pending</option>
<option value="failed" {{ request('status') === 'failed' ? 'selected' : '' }}>Failed</option>
</select>
<select name="visibility" class="adm-select">
<option value="">All Visibility</option>
<option value="public" {{ request('visibility') === 'public' ? 'selected' : '' }}>Public</option>
<option value="unlisted" {{ request('visibility') === 'unlisted' ? 'selected' : '' }}>Unlisted</option>
<option value="private" {{ request('visibility') === 'private' ? 'selected' : '' }}>Private</option>
</select>
<select name="type" class="adm-select">
<option value="">All Types</option>
<option value="generic" {{ request('type') === 'generic' ? 'selected' : '' }}>Generic</option>
<option value="music" {{ request('type') === 'music' ? 'selected' : '' }}>Music</option>
<option value="match" {{ request('type') === 'match' ? 'selected' : '' }}>Match</option>
</select>
<select name="sort" class="adm-select">
<option value="latest" {{ request('sort', 'latest') === 'latest' ? 'selected' : '' }}>Newest first</option>
<option value="oldest" {{ request('sort') === 'oldest' ? 'selected' : '' }}>Oldest first</option>
<option value="title_asc" {{ request('sort') === 'title_asc' ? 'selected' : '' }}>Title A–Z</option>
<option value="title_desc" {{ request('sort') === 'title_desc' ? 'selected' : '' }}>Title Z–A</option>
</select>
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-funnel"></i> Filter
</button>
@if(request()->hasAny(['search','status','visibility','type','sort']))
<a href="{{ route('admin.videos') }}" class="adm-btn">
<i class="bi bi-x-lg"></i> Clear
</a>
@endif
</form>
</div>
</div>
{{-- ── Videos table ─────────────────────────────────────────────────── --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title">
<i class="bi bi-play-circle"></i>
All Videos
<span class="adm-badge adm-badge-user">{{ $videos->total() ?? $videos->count() }}</span>
</div>
</div>
<div class="adm-table-wrap">
<table class="adm-table">
<thead>
<tr>
<th>Video</th>
<th>Owner</th>
<th>Status</th>
<th>Visibility</th>
<th>Type</th>
<th>Views</th>
<th>Likes</th>
<th>Shares</th>
<th>Uploaded</th>
<th style="width:110px; text-align:right;">Actions</th>
</tr>
</thead>
<tbody>
@forelse($videos as $video)
<tr style="cursor:pointer;" onclick="window.open('{{ route('videos.show', $video) }}', '_blank')">
{{-- Thumbnail + title --}}
<td>
<div style="display:flex; align-items:center; gap:12px;">
@if($video->thumbnail)
<img src="{{ route('media.thumbnail', $video->thumbnail) }}"
alt="" style="width:72px;height:44px;object-fit:cover;border-radius:6px;flex-shrink:0;border:1px solid var(--border);">
@else
<div style="width:72px;height:44px;border-radius:6px;background:var(--bg-card2);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i class="bi bi-play-circle" style="color:var(--text-3);font-size:18px;"></i>
</div>
@endif
<div style="min-width:0;">
{{-- Full title in the title attribute so the
admin can hover and read the whole thing
when the visible row ellipsizes. --}}
<div style="font-weight:500;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;"
title="{{ $video->title }}">
{{ $video->title }}
</div>
@if($video->description)
@php
// Descriptions are stored as sanitized rich HTML
// (see HtmlSanitizer). Blade's {{ }} would show
// the escaped tags verbatim in the admin table,
// so strip them to a plain-text preview — and
// put the full stripped text in the tooltip too.
$descPlain = trim(preg_replace('/\s+/', ' ', strip_tags((string) $video->description)));
@endphp
<div style="font-size:11px;color:var(--text-2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;"
title="{{ $descPlain }}">
{{ Str::limit($descPlain, 55) }}
</div>
@endif
</div>
</div>
</td>
{{-- Owner --}}
<td onclick="event.stopPropagation()">
<a href="{{ route('channel', $video->user->channel) }}" target="_blank"
style="font-size:13px;color:var(--text);text-decoration:none;display:flex;align-items:center;gap:4px;">
{{ $video->user->name }}
<i class="bi bi-box-arrow-up-right" style="font-size:10px;opacity:.4;"></i>
</a>
</td>
{{-- Status --}}
<td>
@php
$statusMap = [
'ready' => ['adm-badge-ready', 'bi-check-circle-fill', 'Ready'],
'processing' => ['adm-badge-unlisted','bi-arrow-repeat', 'Processing'],
'pending' => ['adm-badge-unverified','bi-clock', 'Pending'],
'failed' => ['adm-badge-superadmin','bi-x-circle-fill', 'Failed'],
];
[$cls, $ico, $lbl] = $statusMap[$video->status] ?? ['adm-badge-user','bi-dash','Unknown'];
@endphp
<span class="adm-badge {{ $cls }}"><i class="bi {{ $ico }}"></i> {{ $lbl }}</span>
</td>
{{-- Visibility --}}
<td>
@php
$visMap = [
'public' => ['adm-badge-ready', 'bi-globe2', 'Public'],
'unlisted' => ['adm-badge-unlisted','bi-link-45deg', 'Unlisted'],
'private' => ['adm-badge-private', 'bi-lock-fill', 'Private'],
];
[$vcls, $vico, $vlbl] = $visMap[$video->visibility] ?? ['adm-badge-user','bi-question','—'];
@endphp
<span class="adm-badge {{ $vcls }}"><i class="bi {{ $vico }}"></i> {{ $vlbl }}</span>
</td>
{{-- Type --}}
<td>
@php
$typeMap = [
'generic' => ['bi-play-circle','#94a3b8'],
'music' => ['bi-music-note-beamed','#a78bfa'],
'match' => ['bi-trophy','#fbbf24'],
];
[$tico, $tcol] = $typeMap[$video->type] ?? ['bi-play-circle','#94a3b8'];
@endphp
<span style="display:inline-flex;align-items:center;gap:5px;font-size:12px;color:{{ $tcol }};">
<i class="bi {{ $tico }}"></i>
{{ ucfirst($video->type) }}
</span>
</td>
{{-- Views --}}
<td style="font-size:13px;color:var(--text-2);">
{{ number_format(\DB::table('video_views')->where('video_id',$video->id)->count()) }}
</td>
{{-- Likes --}}
<td style="font-size:13px;color:var(--text-2);">
{{ number_format(\DB::table('video_likes')->where('video_id',$video->id)->count()) }}
</td>
{{-- Shares --}}
<td style="font-size:13px;color:var(--text-2);">
{{ number_format(\DB::table('video_shares')->where('video_id',$video->id)->count()) }}
</td>
{{-- Date --}}
<td class="text-muted-sm">{{ $video->created_at->format('M d, Y') }}</td>
{{-- Actions --}}
<td onclick="event.stopPropagation()">
<div class="adm-row-actions" style="justify-content:flex-end;">
<a href="{{ route('videos.show', $video) }}" target="_blank"
class="adm-btn adm-btn-sm" title="Watch">
<i class="bi bi-play"></i>
</a>
<a href="{{ route('admin.videos.edit', $video) }}"
class="adm-btn adm-btn-sm" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<button type="button"
class="adm-btn adm-btn-sm adm-btn-danger"
title="Delete"
onclick="openDeleteDialog('{{ $video->getRouteKey() }}', '{{ addslashes($video->title) }}')">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="10">
<div class="empty-state">
<i class="bi bi-play-circle"></i>
<p>No videos found</p>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{-- Pagination --}}
@if($videos instanceof \Illuminate\Pagination\LengthAwarePaginator && $videos->hasPages())
<div style="padding:16px 20px; border-top:1px solid var(--border);">
{{ $videos->onEachSide(1)->links() }}
</div>
@endif
</div>
{{-- ── Delete confirmation dialog ───────────────────────────────────── --}}
<div class="adm-dialog-overlay" id="deleteDialog">
<div class="adm-dialog">
<div class="adm-dialog-header">
<div class="adm-dialog-title">
<i class="bi bi-exclamation-triangle-fill"></i> Delete Video
</div>
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDeleteDialog()"
style="border:none;background:none;color:var(--text-2);">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="adm-dialog-body">
<p>You are about to permanently delete <strong id="dlgVideoTitle"></strong>.</p>
<div class="adm-dialog-warning">
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
All views, likes, comments, and HLS files for this video will also be deleted.
</div>
@if($adminNeedsOtp)
<div style="margin-top:14px;">
<label style="font-size:13px;color:var(--text-2);display:flex;align-items:center;gap:6px;margin-bottom:6px;">
<i class="bi bi-shield-lock-fill" style="color:#a78bfa;"></i>
Enter your <strong style="color:var(--text);">2FA code</strong> to confirm
</label>
<input type="text" id="dlgVideoOtp" inputmode="numeric" pattern="[0-9]*"
maxlength="6" autocomplete="one-time-code"
placeholder="000000"
style="width:100%;background:var(--bg-card2);border:1px solid var(--border-light);border-radius:8px;padding:10px 14px;color:#fff;font-size:22px;letter-spacing:0.3em;text-align:center;">
</div>
@endif
<div id="dlgVideoError" style="display:none;margin-top:10px;padding:8px 12px;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.3);border-radius:6px;color:#f87171;font-size:13px;"></div>
</div>
<div class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDeleteDialog()">Cancel</button>
<button type="button" class="adm-btn adm-btn-danger" id="dlgVideoConfirmBtn" onclick="confirmDeleteVideo()">
<i class="bi bi-trash"></i> Delete Video
</button>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
const _adminNeedsOtp = {{ $adminNeedsOtp ? 'true' : 'false' }};
let _deleteVideoId = null;
function openDeleteDialog(videoId, videoTitle) {
_deleteVideoId = videoId;
document.getElementById('dlgVideoTitle').textContent = videoTitle;
document.getElementById('dlgVideoError').style.display = 'none';
if (_adminNeedsOtp) document.getElementById('dlgVideoOtp').value = '';
document.getElementById('deleteDialog').classList.add('open');
if (_adminNeedsOtp) setTimeout(() => document.getElementById('dlgVideoOtp').focus(), 100);
}
function closeDeleteDialog() {
document.getElementById('deleteDialog').classList.remove('open');
_deleteVideoId = null;
}
function confirmDeleteVideo() {
if (!_deleteVideoId) return;
const btn = document.getElementById('dlgVideoConfirmBtn');
const errEl = document.getElementById('dlgVideoError');
const otpCode = _adminNeedsOtp ? document.getElementById('dlgVideoOtp').value.replace(/\s/g,'') : '';
if (_adminNeedsOtp && otpCode.length !== 6) {
errEl.textContent = 'Please enter your 6-digit 2FA code.';
errEl.style.display = 'block';
document.getElementById('dlgVideoOtp').focus();
return;
}
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Deleting…';
errEl.style.display = 'none';
const body = new URLSearchParams({ '_token': '{{ csrf_token() }}', '_method': 'DELETE' });
if (_adminNeedsOtp) body.append('otp_code', otpCode);
fetch('/admin/videos/' + _deleteVideoId, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
})
.then(r => r.json())
.then(data => {
if (data.success) {
closeDeleteDialog();
window.location.reload();
} else {
errEl.textContent = data.message || 'Delete failed.';
errEl.style.display = 'block';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i> Delete Video';
if (_adminNeedsOtp) { document.getElementById('dlgVideoOtp').value = ''; document.getElementById('dlgVideoOtp').focus(); }
}
})
.catch(() => {
errEl.textContent = 'An error occurred. Please try again.';
errEl.style.display = 'block';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i> Delete Video';
});
}
document.getElementById('deleteDialog').addEventListener('click', function(e) {
if (e.target === this) closeDeleteDialog();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeDeleteDialog();
});
</script>
@endsection