Add NAS file manager integration and all pending platform changes

- Installed p7h/nas-file-manager package via private VCS repo
- Published config/nas-file-manager.php with super_admin middleware restriction
- Added NAS env vars to .env.example
- Created admin/nas-storage page with connection info panel and file browser widget
- Added NAS Storage link to admin sidebar (super_admin only)
- Added SuperAdminController@nasStorage method and admin.nas-storage route
- Includes all accumulated branch changes: profile wall, 2FA, audit logs,
  settings panel, country/phone/timezone components, posts, slideshow,
  playlist shares, video downloads/shares, comment likes, notifications,
  social links, and more

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
ghassanandClaude Sonnet 4.6 committed 2026-05-13 13:24:32 +03:00
1 parent d44490dfe0
commit 0b2e95ea65
161 files changed
+27496 -7250

No files matched your search

+385
View File
@@ -0,0 +1,385 @@
@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">
@if($logs->onFirstPage())
<span class="disabled"><i class="bi bi-chevron-left"></i></span>
@else
<a href="{{ $logs->previousPageUrl() }}"><i class="bi bi-chevron-left"></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">{{ $page }}</span>
@else
<a href="{{ $url }}">{{ $page }}</a>
@endif
@endforeach
@if($logs->hasMorePages())
<a href="{{ $logs->nextPageUrl() }}"><i class="bi bi-chevron-right"></i></a>
@else
<span class="disabled"><i class="bi bi-chevron-right"></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
File diff suppressed because it is too large. Load diff
+272 -93
View File
@@ -3,131 +3,310 @@
@section('title', 'Edit User')
@section('page_title', 'Edit User')
@section('extra_styles')
<style>
.ef-grid {
display: grid;
grid-template-columns: 1fr 320px;
gap: 20px;
align-items: start;
}
@media (max-width: 900px) {
.ef-grid { grid-template-columns: 1fr; }
}
/* ── Form fields ── */
.ef-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; }
.ef-field:last-of-type { margin-bottom: 0; }
.ef-label {
font-size: 12px; font-weight: 600; color: var(--text-2);
text-transform: uppercase; letter-spacing: .04em;
}
.ef-input, .ef-select, .ef-textarea {
width: 100%;
background: var(--bg-2);
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
font-size: 13px;
outline: none;
transition: border-color .15s;
font-family: inherit;
}
.ef-input, .ef-select { height: 38px; padding: 0 12px; }
.ef-select { cursor: pointer; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23888' viewBox='0 0 16 16'%3E%3Cpath d='M7.247 11.14L2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px;
}
.ef-textarea { height: 90px; padding: 10px 12px; resize: vertical; }
.ef-input:focus, .ef-select:focus, .ef-textarea:focus { border-color: var(--brand); }
.ef-input.is-invalid, .ef-select.is-invalid { border-color: #f87171; }
.ef-error { font-size: 12px; color: #f87171; margin-top: 2px; }
.ef-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 500px) { .ef-row { grid-template-columns: 1fr; } }
/* ── Section divider ── */
.ef-section {
margin: 24px 0 20px;
padding-top: 20px;
border-top: 1px solid var(--border);
display: flex; align-items: center; gap: 10px;
}
.ef-section-title {
font-size: 13px; font-weight: 600; color: var(--text-2);
white-space: nowrap;
}
.ef-section-hint { font-size: 12px; color: var(--text-3); }
/* ── Actions ── */
.ef-actions { display: flex; gap: 10px; margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border); }
/* ── Sidebar info card ── */
.ef-user-avatar {
width: 72px; height: 72px; border-radius: 50%; object-fit: cover;
border: 3px solid var(--border);
}
.ef-stat-row {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,.04);
font-size: 13px;
}
.ef-stat-row:last-child { border-bottom: none; padding-bottom: 0; }
.ef-stat-label { color: var(--text-2); }
.ef-stat-val { font-weight: 600; color: var(--text); }
/* ── Danger zone ── */
.ef-danger-zone {
margin-top: 16px;
padding: 16px;
border: 1px solid rgba(248,113,113,.25);
border-radius: 10px;
background: rgba(248,113,113,.05);
}
.ef-danger-title { font-size: 12px; font-weight: 700; color: #f87171; margin-bottom: 10px; text-transform: uppercase; letter-spacing: .05em; }
</style>
@endsection
@section('content')
<!-- Alerts -->
{{-- ── Page header ── --}}
<div class="adm-page-header" style="margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:12px;">
<a href="{{ route('admin.users') }}" class="adm-btn adm-btn-sm">
<i class="bi bi-arrow-left"></i>
</a>
<h1 class="adm-page-title" style="margin:0;">
<i class="bi bi-person-gear"></i> Edit User
</h1>
</div>
</div>
{{-- ── Alerts ── --}}
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="adm-alert adm-alert-success" style="margin-bottom:16px;">
<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="alert alert-danger alert-dismissible fade show" role="alert">
{{ session('error') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="adm-alert adm-alert-error" style="margin-bottom:16px;">
<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
<div class="row">
<div class="col-lg-8">
<!-- Edit User Form -->
<div class="admin-card">
<div class="admin-card-header">
<h5 class="admin-card-title">Edit User</h5>
<a href="{{ route('admin.users') }}" class="btn btn-outline-light btn-sm">
<i class="bi bi-arrow-left"></i> Back to Users
</a>
</div>
<div class="ef-grid">
{{-- ── Left: edit form ── --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title"><i class="bi bi-pencil-square"></i> Account Details</div>
</div>
<div class="adm-card-body">
<form method="POST" action="{{ route('admin.users.update', $user->id) }}">
@csrf
@method('PUT')
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input type="text" class="form-control @error('name') is-invalid @enderror" id="name" name="name" value="{{ old('name', $user->name) }}" required>
@error('name')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- Name --}}
<div class="ef-field">
<label class="ef-label" for="name">Full Name</label>
<input class="ef-input @error('name') is-invalid @enderror"
id="name" name="name" type="text"
value="{{ old('name', $user->name) }}" required autocomplete="off">
@error('name')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control @error('email') is-invalid @enderror" id="email" name="email" value="{{ old('email', $user->email) }}" required>
@error('email')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- Email --}}
<div class="ef-field">
<label class="ef-label" for="email">Email Address</label>
<input class="ef-input @error('email') is-invalid @enderror"
id="email" name="email" type="email"
value="{{ old('email', $user->email) }}" required autocomplete="off">
@error('email')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="mb-3">
<label for="role" class="form-label">Role</label>
<select class="form-select @error('role') is-invalid @enderror" id="role" name="role" required>
<option value="user" {{ old('role', $user->role) == 'user' ? 'selected' : '' }}>User</option>
<option value="admin" {{ old('role', $user->role) == 'admin' ? 'selected' : '' }}>Admin</option>
<option value="super_admin" {{ old('role', $user->role) == 'super_admin' ? 'selected' : '' }}>Super Admin</option>
{{-- Role --}}
<div class="ef-field">
<label class="ef-label" for="role">Role</label>
<select class="ef-select @error('role') is-invalid @enderror" id="role" name="role">
<option value="user" {{ old('role', $user->role) === 'user' ? 'selected' : '' }}>User</option>
<option value="admin" {{ old('role', $user->role) === 'admin' ? 'selected' : '' }}>Admin</option>
<option value="super_admin" {{ old('role', $user->role) === 'super_admin' ? 'selected' : '' }}>Super Admin</option>
</select>
@error('role')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
@error('role')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<hr style="border-color: var(--border-color);">
<h6 class="mb-3">Change Password (Optional)</h6>
<div class="mb-3">
<label for="new_password" class="form-label">New Password</label>
<input type="password" class="form-control @error('new_password') is-invalid @enderror" id="new_password" name="new_password" placeholder="Leave blank to keep current password">
@error('new_password')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- Password section --}}
<div class="ef-section">
<span class="ef-section-title"><i class="bi bi-lock-fill" style="color:var(--brand);"></i> Change Password</span>
<span class="ef-section-hint">— leave blank to keep current password</span>
</div>
<div class="mb-3">
<label for="new_password_confirmation" class="form-label">Confirm New Password</label>
<input type="password" class="form-control" id="new_password_confirmation" name="new_password_confirmation" placeholder="Confirm new password">
<div class="ef-row">
<div class="ef-field" style="margin-bottom:0;">
<label class="ef-label" for="new_password">New Password</label>
<input class="ef-input @error('new_password') is-invalid @enderror"
id="new_password" name="new_password" type="password"
placeholder="Min. 8 characters" autocomplete="new-password">
@error('new_password')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="ef-field" style="margin-bottom:0;">
<label class="ef-label" for="new_password_confirmation">Confirm Password</label>
<input class="ef-input"
id="new_password_confirmation" name="new_password_confirmation"
type="password" placeholder="Repeat password" autocomplete="new-password">
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Update User
<div class="ef-actions">
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-check-circle-fill"></i> Save Changes
</button>
<a href="{{ route('admin.users') }}" class="btn btn-outline-light">Cancel</a>
<a href="{{ route('admin.users') }}" class="adm-btn">Cancel</a>
</div>
</form>
</div>
</div>
<div class="col-lg-4">
<!-- User Info -->
<div class="admin-card">
<div class="admin-card-header">
<h5 class="admin-card-title">User Info</h5>
</div>
<div class="text-center mb-3">
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="rounded-circle" style="width: 80px; height: 80px; object-fit: cover;">
<h5 class="mt-2">{{ $user->name }}</h5>
<p class="text-secondary mb-1">{{ $user->email }}</p>
{{-- ── Right: user info sidebar ── --}}
<div style="display:flex;flex-direction:column;gap:16px;">
{{-- Profile card --}}
<div class="adm-card">
<div class="adm-card-body" style="text-align:center;padding-top:28px;padding-bottom:24px;">
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="ef-user-avatar">
<div style="margin-top:12px;font-size:16px;font-weight:700;color:var(--text);">{{ $user->name }}</div>
<div style="font-size:12px;color:var(--text-2);margin:4px 0 10px;">{{ $user->email }}</div>
@if($user->role === 'super_admin')
<span class="badge-role badge-super-admin">Super Admin</span>
<span class="adm-badge adm-badge-superadmin">Super Admin</span>
@elseif($user->role === 'admin')
<span class="badge-role badge-admin">Admin</span>
<span class="adm-badge adm-badge-admin">Admin</span>
@else
<span class="badge-role badge-user">User</span>
<span class="adm-badge adm-badge-user">User</span>
@endif
</div>
<hr style="border-color: var(--border-color);">
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">User ID</span>
<span>#{{ $user->id }}</span>
</div>
{{-- Stats card --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title"><i class="bi bi-info-circle"></i> Account Info</div>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Joined</span>
<span>{{ $user->created_at->format('M d, Y') }}</span>
<div class="adm-card-body">
<div class="ef-stat-row">
<span class="ef-stat-label">User ID</span>
<span class="ef-stat-val">#{{ $user->id }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Joined</span>
<span class="ef-stat-val">{{ $user->created_at->format('M d, Y') }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Last active</span>
<span class="ef-stat-val">{{ $user->updated_at->diffForHumans() }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Videos</span>
<span class="ef-stat-val" style="color:#818cf8;">{{ $user->videos->count() }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Email verified</span>
@if($user->email_verified_at)
<span class="adm-badge adm-badge-verified" style="font-size:11px;">Verified</span>
@else
<span class="adm-badge adm-badge-unverified" style="font-size:11px;">Unverified</span>
@endif
</div>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Total Videos</span>
<span>{{ $user->videos->count() }}</span>
</div>
{{-- Impersonate --}}
@if(!$user->isSuperAdmin())
<form method="POST" action="{{ route('admin.users.impersonate', $user->id) }}">
@csrf
<button type="submit" class="adm-btn adm-btn-impersonate" style="width:100%;">
<i class="bi bi-person-badge"></i> Impersonate User
</button>
</form>
@endif
{{-- Danger zone --}}
@if(!$user->isSuperAdmin())
<div class="ef-danger-zone">
<div class="ef-danger-title"><i class="bi bi-exclamation-triangle-fill me-1"></i> Danger Zone</div>
<button type="button" class="adm-btn adm-btn-danger" style="width:100%;"
onclick="openDelDialog()">
<i class="bi bi-trash3-fill"></i> Delete This User
</button>
</div>
@endif
</div>
</div>
{{-- ── Delete confirmation dialog ── --}}
@if(!$user->isSuperAdmin())
<div class="adm-dialog-overlay" id="delDialog">
<div class="adm-dialog">
<div class="adm-dialog-header">
<div class="adm-dialog-title">
<i class="bi bi-exclamation-triangle-fill"></i> Delete User
</div>
<div class="d-flex justify-content-between">
<span class="text-secondary">Email Verified</span>
<span>{{ $user->email_verified_at ? 'Yes' : 'No' }}</span>
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDelDialog()"
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>{{ $user->name }}</strong>.</p>
<div class="adm-dialog-warning">
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
All videos uploaded by this user will also be deleted. This cannot be undone.
</div>
</div>
<div class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDelDialog()">Cancel</button>
<form method="POST" action="{{ route('admin.users.delete', $user->id) }}">
@csrf @method('DELETE')
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
<i class="bi bi-trash3-fill"></i> Delete Permanently
</button>
</form>
</div>
</div>
</div>
@endif
@endsection
@section('scripts')
<script>
function openDelDialog() { document.getElementById('delDialog').classList.add('open'); }
function closeDelDialog() { document.getElementById('delDialog').classList.remove('open'); }
document.getElementById('delDialog')?.addEventListener('click', e => { if (e.target === document.getElementById('delDialog')) closeDelDialog(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDelDialog(); });
</script>
@endsection
+276 -128
View File
@@ -3,164 +3,312 @@
@section('title', 'Edit Video')
@section('page_title', 'Edit Video')
@section('extra_styles')
<style>
.ef-grid {
display: grid;
grid-template-columns: 1fr 300px;
gap: 20px;
align-items: start;
}
@media (max-width: 900px) {
.ef-grid { grid-template-columns: 1fr; }
}
/* ── Form fields ── */
.ef-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; }
.ef-field:last-of-type { margin-bottom: 0; }
.ef-label {
font-size: 12px; font-weight: 600; color: var(--text-2);
text-transform: uppercase; letter-spacing: .04em;
}
.ef-input, .ef-select, .ef-textarea {
width: 100%;
background: var(--bg-2);
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
font-size: 13px;
outline: none;
transition: border-color .15s;
font-family: inherit;
}
.ef-input, .ef-select { height: 38px; padding: 0 12px; }
.ef-select { cursor: pointer; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23888' viewBox='0 0 16 16'%3E%3Cpath d='M7.247 11.14L2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px;
}
.ef-textarea { height: 100px; padding: 10px 12px; resize: vertical; }
.ef-input:focus, .ef-select:focus, .ef-textarea:focus { border-color: var(--brand); }
.ef-input.is-invalid, .ef-select.is-invalid, .ef-textarea.is-invalid { border-color: #f87171; }
.ef-error { font-size: 12px; color: #f87171; margin-top: 2px; }
.ef-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
@media (max-width: 600px) { .ef-row-3 { grid-template-columns: 1fr; } }
/* ── Actions ── */
.ef-actions { display: flex; gap: 10px; margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border); }
/* ── Sidebar stats ── */
.ef-thumb {
width: 100%; border-radius: 10px; object-fit: cover; display: block;
background: var(--bg-2);
}
.ef-thumb-placeholder {
width: 100%; height: 160px; border-radius: 10px;
background: var(--bg-2); border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center;
color: var(--text-3); font-size: 36px;
}
.ef-stat-row {
display: flex; justify-content: space-between; align-items: center;
padding: 9px 0; border-bottom: 1px solid rgba(255,255,255,.04);
font-size: 13px;
}
.ef-stat-row:last-child { border-bottom: none; padding-bottom: 0; }
.ef-stat-label { color: var(--text-2); }
.ef-stat-val { font-weight: 600; color: var(--text); }
/* ── Danger zone ── */
.ef-danger-zone {
padding: 16px;
border: 1px solid rgba(248,113,113,.25);
border-radius: 10px;
background: rgba(248,113,113,.05);
}
.ef-danger-title { font-size: 12px; font-weight: 700; color: #f87171; margin-bottom: 10px; text-transform: uppercase; letter-spacing: .05em; }
</style>
@endsection
@section('content')
<!-- Alerts -->
{{-- ── Page header ── --}}
<div class="adm-page-header" style="margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:12px;">
<a href="{{ route('admin.videos') }}" class="adm-btn adm-btn-sm">
<i class="bi bi-arrow-left"></i>
</a>
<h1 class="adm-page-title" style="margin:0;">
<i class="bi bi-pencil-square"></i> Edit Video
</h1>
</div>
</div>
{{-- ── Alerts ── --}}
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="adm-alert adm-alert-success" style="margin-bottom:16px;">
<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="alert alert-danger alert-dismissible fade show" role="alert">
{{ session('error') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="adm-alert adm-alert-error" style="margin-bottom:16px;">
<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
<div class="row">
<div class="col-lg-8">
<!-- Edit Video Form -->
<div class="admin-card">
<div class="admin-card-header">
<h5 class="admin-card-title">Edit Video</h5>
<a href="{{ route('admin.videos') }}" class="btn btn-outline-light btn-sm">
<i class="bi bi-arrow-left"></i> Back to Videos
</a>
</div>
<form method="POST" action="{{ route('admin.videos.update', $video->id) }}">
<div class="ef-grid">
{{-- ── Left: form ── --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title"><i class="bi bi-film"></i> Video Details</div>
</div>
<div class="adm-card-body">
<form method="POST" action="{{ route('admin.videos.update', $video) }}">
@csrf
@method('PUT')
<div class="mb-3">
<label for="title" class="form-label">Title</label>
<input type="text" class="form-control @error('title') is-invalid @enderror" id="title" name="title" value="{{ old('title', $video->title) }}" required>
@error('title')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- Title --}}
<div class="ef-field">
<label class="ef-label" for="title">Title</label>
<input class="ef-input @error('title') is-invalid @enderror"
id="title" name="title" type="text"
value="{{ old('title', $video->title) }}" required>
@error('title')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control @error('description') is-invalid @enderror" id="description" name="description" rows="4">{{ old('description', $video->description) }}</textarea>
@error('description')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- Description --}}
<div class="ef-field">
<label class="ef-label" for="description">Description</label>
<textarea class="ef-textarea @error('description') is-invalid @enderror"
id="description" name="description">{{ old('description', $video->description) }}</textarea>
@error('description')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="row mb-3">
<div class="col-md-4">
<label for="visibility" class="form-label">Visibility</label>
<select class="form-select @error('visibility') is-invalid @enderror" id="visibility" name="visibility" required>
<option value="public" {{ old('visibility', $video->visibility) == 'public' ? 'selected' : '' }}>Public</option>
<option value="unlisted" {{ old('visibility', $video->visibility) == 'unlisted' ? 'selected' : '' }}>Unlisted</option>
<option value="private" {{ old('visibility', $video->visibility) == 'private' ? 'selected' : '' }}>Private</option>
{{-- Visibility / Type / Status --}}
<div class="ef-row-3">
<div class="ef-field" style="margin-bottom:0;">
<label class="ef-label" for="visibility">Visibility</label>
<select class="ef-select @error('visibility') is-invalid @enderror" id="visibility" name="visibility">
<option value="public" {{ old('visibility', $video->visibility) === 'public' ? 'selected' : '' }}>Public</option>
<option value="unlisted" {{ old('visibility', $video->visibility) === 'unlisted' ? 'selected' : '' }}>Unlisted</option>
<option value="private" {{ old('visibility', $video->visibility) === 'private' ? 'selected' : '' }}>Private</option>
</select>
@error('visibility')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
@error('visibility')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="col-md-4">
<label for="type" class="form-label">Type</label>
<select class="form-select @error('type') is-invalid @enderror" id="type" name="type" required>
<option value="generic" {{ old('type', $video->type) == 'generic' ? 'selected' : '' }}>Generic</option>
<option value="music" {{ old('type', $video->type) == 'music' ? 'selected' : '' }}>Music</option>
<option value="match" {{ old('type', $video->type) == 'match' ? 'selected' : '' }}>Match</option>
<div class="ef-field" style="margin-bottom:0;">
<label class="ef-label" for="type">Type</label>
<select class="ef-select @error('type') is-invalid @enderror" id="type" name="type">
<option value="generic" {{ old('type', $video->type) === 'generic' ? 'selected' : '' }}>Generic</option>
<option value="music" {{ old('type', $video->type) === 'music' ? 'selected' : '' }}>Music</option>
<option value="match" {{ old('type', $video->type) === 'match' ? 'selected' : '' }}>Match</option>
</select>
@error('type')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
@error('type')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="col-md-4">
<label for="status" class="form-label">Status</label>
<select class="form-select @error('status') is-invalid @enderror" id="status" name="status" required>
<option value="pending" {{ old('status', $video->status) == 'pending' ? 'selected' : '' }}>Pending</option>
<option value="processing" {{ old('status', $video->status) == 'processing' ? 'selected' : '' }}>Processing</option>
<option value="ready" {{ old('status', $video->status) == 'ready' ? 'selected' : '' }}>Ready</option>
<option value="failed" {{ old('status', $video->status) == 'failed' ? 'selected' : '' }}>Failed</option>
<div class="ef-field" style="margin-bottom:0;">
<label class="ef-label" for="status">Status</label>
<select class="ef-select @error('status') is-invalid @enderror" id="status" name="status">
<option value="pending" {{ old('status', $video->status) === 'pending' ? 'selected' : '' }}>Pending</option>
<option value="processing" {{ old('status', $video->status) === 'processing' ? 'selected' : '' }}>Processing</option>
<option value="ready" {{ old('status', $video->status) === 'ready' ? 'selected' : '' }}>Ready</option>
<option value="failed" {{ old('status', $video->status) === 'failed' ? 'selected' : '' }}>Failed</option>
</select>
@error('status')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
@error('status')<div class="ef-error">{{ $message }}</div>@enderror
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Update Video
{{-- Download Access --}}
<div class="ef-field" style="margin-top:18px;">
<label class="ef-label" for="download_access">Who Can Download</label>
<select class="ef-select @error('download_access') is-invalid @enderror" id="download_access" name="download_access">
<option value="disabled" {{ old('download_access', $video->download_access) === 'disabled' ? 'selected' : '' }}>No one (disabled)</option>
<option value="everyone" {{ old('download_access', $video->download_access) === 'everyone' ? 'selected' : '' }}>Everyone (guests too)</option>
<option value="registered" {{ old('download_access', $video->download_access) === 'registered' ? 'selected' : '' }}>Registered members</option>
<option value="subscribers" {{ old('download_access', $video->download_access) === 'subscribers' ? 'selected' : '' }}>Subscribers only</option>
</select>
@error('download_access')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="ef-actions">
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-check-circle-fill"></i> Save Changes
</button>
<a href="{{ route('admin.videos') }}" class="btn btn-outline-light">Cancel</a>
<a href="{{ route('admin.videos') }}" class="adm-btn">Cancel</a>
<a href="{{ route('videos.show', $video) }}" target="_blank" class="adm-btn" style="margin-left:auto;">
<i class="bi bi-play-circle"></i> View Video
</a>
</div>
</form>
</div>
</div>
<div class="col-lg-4">
<!-- Video Info -->
<div class="admin-card">
<div class="admin-card-header">
<h5 class="admin-card-title">Video Info</h5>
{{-- ── Right: sidebar ── --}}
<div style="display:flex;flex-direction:column;gap:16px;">
{{-- Thumbnail --}}
<div class="adm-card">
<div class="adm-card-body">
@if($video->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}"
alt="{{ $video->title }}" class="ef-thumb">
@else
<div class="ef-thumb-placeholder"><i class="bi bi-play-circle"></i></div>
@endif
</div>
@if($video->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}" class="img-fluid rounded mb-3" style="width: 100%;">
@else
<div class="bg-secondary rounded d-flex align-items-center justify-content-center mb-3" style="height: 180px;">
<i class="bi bi-play-circle text-white" style="font-size: 3rem;"></i>
</div>
{{-- Stats --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title"><i class="bi bi-info-circle"></i> Video Info</div>
</div>
<div class="adm-card-body">
<div class="ef-stat-row">
<span class="ef-stat-label">Owner</span>
<a href="{{ route('channel', $video->user->channel) }}" target="_blank"
style="color:var(--brand);font-weight:600;font-size:13px;text-decoration:none;">
{{ $video->user->name }}
</a>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Uploaded</span>
<span class="ef-stat-val">{{ $video->created_at->format('M d, Y') }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Views</span>
<span class="ef-stat-val" style="color:#22d3ee;">
{{ number_format(\DB::table('video_views')->where('video_id', $video->id)->count()) }}
</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Likes</span>
<span class="ef-stat-val" style="color:#f472b6;">
{{ number_format(\DB::table('video_likes')->where('video_id', $video->id)->count()) }}
</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Duration</span>
<span class="ef-stat-val">{{ $video->duration ? gmdate('H:i:s', $video->duration) : '—' }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Dimensions</span>
<span class="ef-stat-val">{{ $video->width ?? '—' }} × {{ $video->height ?? '—' }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">File size</span>
<span class="ef-stat-val">{{ $video->size ? number_format($video->size / 1024 / 1024, 1) . ' MB' : '—' }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Orientation</span>
<span class="ef-stat-val" style="text-transform:capitalize;">{{ $video->orientation ?? '—' }}</span>
</div>
@endif
<hr style="border-color: var(--border-color);">
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Video ID</span>
<span>#{{ $video->id }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Owner</span>
<a href="{{ route('channel', $video->user->id) }}" target="_blank">{{ $video->user->name }}</a>
</div>
{{-- Danger zone --}}
<div class="ef-danger-zone">
<div class="ef-danger-title"><i class="bi bi-exclamation-triangle-fill me-1"></i> Danger Zone</div>
<button type="button" class="adm-btn adm-btn-danger" style="width:100%;"
onclick="openDelDialog()">
<i class="bi bi-trash3-fill"></i> Delete This Video
</button>
</div>
</div>
</div>
{{-- ── Delete confirmation dialog ── --}}
<div class="adm-dialog-overlay" id="delDialog">
<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>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Uploaded</span>
<span>{{ $video->created_at->format('M d, Y') }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">File Size</span>
<span>{{ number_format($video->size / 1024 / 1024, 2) }} MB</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Duration</span>
<span>{{ $video->duration ? gmdate('H:i:s', $video->duration) : 'N/A' }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Orientation</span>
<span class="text-capitalize">{{ $video->orientation }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Dimensions</span>
<span>{{ $video->width ?? 'N/A' }} x {{ $video->height ?? 'N/A' }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Views</span>
<span>{{ number_format(\DB::table('video_views')->where('video_id', $video->id)->count()) }}</span>
</div>
<div class="d-flex justify-content-between">
<span class="text-secondary">Likes</span>
<span>{{ number_format(\DB::table('video_likes')->where('video_id', $video->id)->count()) }}</span>
</div>
<hr style="border-color: var(--border-color);">
<div class="d-grid gap-2">
<a href="{{ route('videos.show', $video->id) }}" target="_blank" class="btn btn-outline-light btn-sm">
<i class="bi bi-play-circle"></i> View Video
</a>
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDelDialog()"
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>{{ $video->title }}</strong>.</p>
<div class="adm-dialog-warning">
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
The video file, thumbnail, and all associated data will be removed. This cannot be undone.
</div>
</div>
<div class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDelDialog()">Cancel</button>
<form method="POST" action="{{ route('admin.videos.delete', $video) }}">
@csrf @method('DELETE')
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
<i class="bi bi-trash3-fill"></i> Delete Permanently
</button>
</form>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
function openDelDialog() { document.getElementById('delDialog').classList.add('open'); }
function closeDelDialog() { document.getElementById('delDialog').classList.remove('open'); }
document.getElementById('delDialog').addEventListener('click', e => { if (e.target === document.getElementById('delDialog')) closeDelDialog(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDelDialog(); });
</script>
@endsection
File diff suppressed because it is too large. Load diff
+100
View File
@@ -0,0 +1,100 @@
@extends('layouts.app')
@section('title', 'Error Logs | Admin')
@section('content')
<div style="max-width: 1400px; margin: 0 auto; padding: 24px;">
{{-- Header --}}
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; flex-wrap: wrap; gap: 12px;">
<div style="display: flex; align-items: center; gap: 12px;">
<a href="{{ route('admin.dashboard') }}" class="action-btn">
<i class="bi bi-arrow-left"></i> <span>Dashboard</span>
</a>
<h1 style="font-size: 22px; font-weight: 700; margin: 0;">
<i class="bi bi-bug-fill" style="color: #e61e1e; margin-right: 8px;"></i>Error Logs
</h1>
</div>
<div style="display: flex; gap: 8px; align-items: center;">
<span style="font-size: 13px; color: var(--text-secondary);">{{ count($lines) }} entries shown</span>
<a href="{{ route('admin.logs') }}" class="action-btn">
<i class="bi bi-arrow-clockwise"></i> <span>Refresh</span>
</a>
<button class="action-btn" onclick="copyAll()">
<i class="bi bi-clipboard"></i> <span>Copy All</span>
</button>
</div>
</div>
{{-- Filters --}}
<form method="GET" action="{{ route('admin.logs') }}"
style="display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; align-items: flex-end;">
<div style="flex: 1; min-width: 200px;">
<label style="display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">Search</label>
<input type="text" name="filter" value="{{ $filter }}"
placeholder="Filter by keyword…"
style="width: 100%; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 8px 12px; font-size: 13px;">
</div>
<div>
<label style="display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">Level</label>
<select name="level"
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 8px 12px; font-size: 13px; cursor: pointer;">
<option value="" {{ $level === '' ? 'selected' : '' }}>All levels</option>
<option value="ERROR" {{ $level === 'ERROR' ? 'selected' : '' }}>ERROR</option>
<option value="WARNING" {{ $level === 'WARNING' ? 'selected' : '' }}>WARNING</option>
<option value="INFO" {{ $level === 'INFO' ? 'selected' : '' }}>INFO</option>
<option value="DEBUG" {{ $level === 'DEBUG' ? 'selected' : '' }}>DEBUG</option>
</select>
</div>
<div>
<label style="display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">Show</label>
<select name="limit"
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 8px 12px; font-size: 13px; cursor: pointer;">
@foreach([50, 100, 200, 500] as $n)
<option value="{{ $n }}" {{ $limit == $n ? 'selected' : '' }}>{{ $n }} lines</option>
@endforeach
</select>
</div>
<button type="submit" class="action-btn action-btn-primary">
<i class="bi bi-search"></i> <span>Filter</span>
</button>
</form>
{{-- Log lines --}}
@if(empty($lines))
<div style="text-align: center; padding: 60px; color: var(--text-secondary);">
<i class="bi bi-check-circle" style="font-size: 48px; color: #22c55e; display: block; margin-bottom: 12px;"></i>
No log entries found{{ $filter || $level ? ' matching your filter' : '' }}.
</div>
@else
<div id="logOutput" style="font-family: 'Courier New', monospace; font-size: 12px; line-height: 1.6; background: #0a0a0a; border: 1px solid var(--border-color); border-radius: 10px; overflow: auto; max-height: calc(100vh - 260px); padding: 12px 16px;">
@foreach($lines as $line)
@php
$color = '#aaa';
if (str_contains($line, '.ERROR:')) $color = '#f87171';
elseif (str_contains($line, '.WARNING:')) $color = '#fbbf24';
elseif (str_contains($line, '.INFO:')) $color = '#6ee7b7';
elseif (str_contains($line, '.DEBUG:')) $color = '#93c5fd';
@endphp
<div class="log-line" style="color: {{ $color }}; border-bottom: 1px solid #1a1a1a; padding: 3px 0; white-space: pre-wrap; word-break: break-all;">{{ $line }}</div>
@endforeach
</div>
@endif
</div>
<script>
function copyAll() {
const text = Array.from(document.querySelectorAll('.log-line'))
.map(el => el.textContent).join('\n');
navigator.clipboard.writeText(text).then(() => showToast('Copied to clipboard', 'success'));
}
// Auto-scroll to top (newest entries are at top)
document.getElementById('logOutput')?.scrollTo(0, 0);
</script>
@endsection
@@ -0,0 +1,89 @@
@extends('admin.layout')
@section('title', 'NAS Storage')
@section('extra_styles')
<style>
/* Tailwind-like resets needed for the NAS component (light-theme widget) */
.nas-wrapper * { box-sizing: border-box; }
/* brand-* color mappings for the NAS file manager component */
.nas-wrapper .bg-brand-600, .nas-wrapper .bg-brand-700 { background-color: #e61e1e !important; }
.nas-wrapper .text-brand-600, .nas-wrapper .text-brand-500 { color: #e61e1e !important; }
.nas-wrapper .ring-brand-500 { --tw-ring-color: #e61e1e !important; }
.nas-wrapper .hover\:bg-brand-700:hover { background-color: #c91a1a !important; }
.nas-wrapper .focus\:ring-brand-500:focus { --tw-ring-color: #e61e1e !important; }
.nas-wrapper .hover\:text-brand-600:hover { color: #e61e1e !important; }
.nas-wrapper .hover\:bg-brand-50:hover { background-color: rgba(230,30,30,.08) !important; }
.nas-wrapper .bg-brand-600.text-white { color: #fff !important; }
</style>
@endsection
@section('content')
<div class="adm-page-header">
<h1 class="adm-page-title">
<i class="bi bi-hdd-network"></i> NAS Storage
</h1>
</div>
{{-- Connection config summary --}}
<div class="adm-card" style="margin-bottom:20px;">
<div class="adm-card-header">
<span class="adm-card-title"><i class="bi bi-plug"></i> Connection</span>
<a href="{{ route('admin.settings') }}" class="adm-btn adm-btn-sm">
<i class="bi bi-gear"></i> Settings
</a>
</div>
<div class="adm-card-body" style="display:flex;gap:32px;flex-wrap:wrap;">
@php
$conn = config('nas-file-manager.connection');
$fields = [
'Protocol' => strtoupper($conn['protocol'] ?? '—'),
'Host' => $conn['host'] ?: '—',
'Port' => $conn['port'] ?: '—',
'Share' => $conn['smb_share'] ?: '—',
'Path' => $conn['path'] ?: '—',
'User' => $conn['username'] ?: '—',
];
@endphp
@foreach($fields as $label => $value)
<div>
<div style="font-size:11px;color:var(--text-2);text-transform:uppercase;letter-spacing:.5px;margin-bottom:3px;">{{ $label }}</div>
<div style="font-size:13px;font-weight:600;font-family:monospace;">{{ $value }}</div>
</div>
@endforeach
</div>
</div>
{{-- NAS File Manager component (Alpine.js + Tailwind) --}}
<div class="nas-wrapper">
@include('nas-file-manager::file-manager', [
'nodes' => $nodes,
'canEdit' => true,
'title' => 'NAS Storage Browser',
])
</div>
@endsection
@section('scripts')
<script src="https://cdn.jsdelivr.net/npm/@alpinejs/[email protected]/dist/cdn.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/base.min.css">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: {
50: 'rgba(230,30,30,.08)',
500: '#e61e1e',
600: '#e61e1e',
700: '#c91a1a',
}
}
}
},
corePlugins: { preflight: false }
}
</script>
@endsection
@@ -0,0 +1,25 @@
<div class="gpu-grid">
@foreach($gpus as $gpu)
@php
$used = $gpu['mem_total'] - $gpu['mem_free'];
$usedPct = $gpu['mem_total'] > 0 ? round($used / $gpu['mem_total'] * 100) : 0;
$sel = (string)$gpu['index'] === (string)$selectedDevice;
@endphp
<div class="gpu-card {{ $sel ? 'selected' : '' }}"
data-index="{{ $gpu['index'] }}"
onclick="selectGpuCard(this)">
<div class="gpu-card-check">@if($sel)<i class="bi bi-check"></i>@endif</div>
<div class="gpu-card-name">{{ $gpu['name'] }}</div>
<div class="gpu-stat"><span>VRAM</span><span class="gpu-stat-val">{{ number_format($gpu['mem_total']) }} MB</span></div>
<div class="gpu-stat"><span>Free</span><span class="gpu-stat-val">{{ number_format($gpu['mem_free']) }} MB</span></div>
<div class="gpu-stat"><span>GPU Load</span><span class="gpu-stat-val">{{ $gpu['util'] }}%</span></div>
<div class="gpu-stat"><span>Temp</span><span class="gpu-stat-val">{{ $gpu['temp'] }} °C</span></div>
<div class="gpu-stat"><span>Driver</span><span class="gpu-stat-val">{{ $gpu['driver'] }}</span></div>
<div class="mem-bar-wrap">
<div class="mem-bar-track">
<div class="mem-bar-fill" style="width:{{ $usedPct }}%"></div>
</div>
</div>
</div>
@endforeach
</div>
@@ -0,0 +1,47 @@
@if ($paginator->hasPages())
<nav class="adm-pagination" aria-label="Pagination">
{{-- Prev --}}
@if ($paginator->onFirstPage())
<span class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-left"></i></span>
</span>
@else
<a class="page-item" href="{{ $paginator->previousPageUrl() }}" rel="prev">
<span class="page-link"><i class="bi bi-chevron-left"></i></span>
</a>
@endif
{{-- Pages --}}
@foreach ($elements as $element)
@if (is_string($element))
<span class="page-item disabled"><span class="page-link">{{ $element }}</span></span>
@endif
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<span class="page-item active" aria-current="page">
<span class="page-link">{{ $page }}</span>
</span>
@else
<a class="page-item" href="{{ $url }}">
<span class="page-link">{{ $page }}</span>
</a>
@endif
@endforeach
@endif
@endforeach
{{-- Next --}}
@if ($paginator->hasMorePages())
<a class="page-item" href="{{ $paginator->nextPageUrl() }}" rel="next">
<span class="page-link"><i class="bi bi-chevron-right"></i></span>
</a>
@else
<span class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-right"></i></span>
</span>
@endif
</nav>
@endif
+430
View File
@@ -0,0 +1,430 @@
@extends('admin.layout')
@section('title', 'System Settings')
@section('page_title', 'Settings')
@section('extra_styles')
<style>
.settings-section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 24px;
overflow: hidden;
}
.settings-section-header {
display: flex; align-items: center; gap: 10px;
padding: 18px 22px;
border-bottom: 1px solid var(--border);
font-size: 14px; font-weight: 600;
}
.settings-section-header i { color: var(--brand); font-size: 16px; }
.settings-section-body { padding: 22px; }
.setting-row {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 24px; padding: 16px 0;
border-bottom: 1px solid var(--border);
}
.setting-row:last-child { border-bottom: none; padding-bottom: 0; }
.setting-row:first-child { padding-top: 0; }
.setting-label { flex: 1; }
.setting-label strong { display: block; font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 3px; }
.setting-label small { font-size: 12px; color: var(--text-2); line-height: 1.5; }
.setting-control { flex-shrink: 0; min-width: 180px; }
/* Toggle switch */
.toggle-wrap { display: flex; align-items: center; gap: 10px; }
.toggle-switch {
position: relative; width: 44px; height: 24px; flex-shrink: 0; cursor: pointer;
}
.toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.toggle-track {
position: absolute; inset: 0;
background: var(--border-light); border-radius: 12px;
transition: background .2s;
}
.toggle-thumb {
position: absolute; top: 3px; left: 3px;
width: 18px; height: 18px; border-radius: 50%;
background: #fff; transition: transform .2s;
box-shadow: 0 1px 3px rgba(0,0,0,.4);
}
.toggle-switch input:checked ~ .toggle-track { background: var(--brand); }
.toggle-switch input:checked ~ .toggle-thumb { transform: translateX(20px); }
.toggle-label { font-size: 13px; color: var(--text-2); }
.toggle-switch input:checked + .toggle-track + .toggle-label { color: var(--text); }
/* Select */
.adm-select-full {
width: 100%; height: 38px;
background: var(--bg); border: 1px solid var(--border-light);
border-radius: 8px; color: var(--text); font-size: 13px;
padding: 0 12px; outline: none; font-family: inherit;
transition: border-color .15s; cursor: pointer;
}
.adm-select-full:focus { border-color: var(--brand); }
.adm-select-full option { background: #1e1e1e; }
/* GPU cards */
.gpu-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-bottom: 4px; }
.gpu-card {
background: var(--bg); border: 1px solid var(--border);
border-radius: 10px; padding: 16px;
cursor: pointer; transition: border-color .15s, background .15s;
position: relative;
}
.gpu-card:hover { border-color: #444; background: var(--bg-card2); }
.gpu-card.selected { border-color: var(--brand); background: var(--brand-dim); }
.gpu-card-check {
position: absolute; top: 10px; right: 10px;
width: 18px; height: 18px; border-radius: 50%;
border: 2px solid var(--border); background: transparent;
display: flex; align-items: center; justify-content: center;
font-size: 10px; color: #fff;
transition: background .15s, border-color .15s;
}
.gpu-card.selected .gpu-card-check { background: var(--brand); border-color: var(--brand); }
.gpu-card-name { font-size: 13px; font-weight: 600; margin-bottom: 8px; padding-right: 24px; }
.gpu-stat { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.gpu-stat:last-child { margin-bottom: 0; }
.gpu-stat-val { color: var(--text); font-weight: 500; }
/* Mem bar */
.mem-bar-wrap { margin-top: 8px; }
.mem-bar-track { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
.mem-bar-fill { height: 100%; background: var(--brand); border-radius: 2px; transition: width .4s; }
/* No GPU state */
.no-gpu-state { text-align: center; padding: 28px 20px; color: var(--text-2); }
.no-gpu-state i { font-size: 32px; display: block; margin-bottom: 10px; opacity: .3; }
/* Status chip */
.chip {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600;
}
.chip-green { background: rgba(34,197,94,.15); color: #4ade80; border: 1px solid rgba(34,197,94,.25); }
.chip-red { background: rgba(239,68,68,.15); color: #f87171; border: 1px solid rgba(239,68,68,.25); }
.chip-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
/* Encoder option cards */
.enc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
.enc-card {
background: var(--bg); border: 1px solid var(--border);
border-radius: 8px; padding: 12px 14px;
cursor: pointer; transition: border-color .15s;
text-align: left;
}
.enc-card:hover { border-color: #444; }
.enc-card.selected { border-color: var(--brand); background: var(--brand-dim); }
.enc-card-name { font-size: 13px; font-weight: 600; display: block; }
.enc-card-desc { font-size: 11px; color: var(--text-2); margin-top: 3px; display: block; }
.save-bar {
display: flex; align-items: center; justify-content: flex-end;
gap: 12px; padding-top: 4px;
}
</style>
@endsection
@section('content')
<div class="adm-page-header">
<h1 class="adm-page-title"><i class="bi bi-gear-fill"></i> System Settings</h1>
</div>
@if(session('success'))
<div class="adm-alert adm-alert-success" style="margin-bottom:20px;">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
</div>
@endif
@if($errors->any())
<div class="adm-alert adm-alert-error" style="margin-bottom:20px;">
<i class="bi bi-exclamation-triangle-fill"></i>
<span>{{ $errors->first() }}</span>
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
</div>
@endif
<form method="POST" action="{{ route('admin.settings.update') }}" id="settingsForm">
@csrf
{{-- ── GPU Processing ───────────────────────────────────────── --}}
<div class="settings-section">
<div class="settings-section-header">
<i class="bi bi-gpu-card"></i>
GPU Accelerated Processing
<span id="gpuStatusChip" style="margin-left:6px;">
@if(count($gpus))
<span class="chip chip-green"><span class="chip-dot"></span> {{ count($gpus) }} GPU{{ count($gpus) > 1 ? 's' : '' }} detected</span>
@else
<span class="chip chip-red"><span class="chip-dot"></span> No GPU detected</span>
@endif
</span>
{{-- NVENC encoding health --}}
<span id="nvencStatusChip" style="margin-left:6px;">
@if($nvencWorks)
<span class="chip chip-green"><span class="chip-dot"></span> NVENC encoding ✓</span>
@else
<span class="chip chip-red"><span class="chip-dot"></span> NVENC encoding ✗</span>
@endif
</span>
</div>
@if(!$nvencWorks && count($gpus))
<div style="background:rgba(239,68,68,.08);border-left:3px solid #f87171;padding:12px 18px;font-size:13px;color:#f87171;line-height:1.6;">
<strong>⚠ NVENC is not working with the current FFmpeg binary.</strong><br>
The GPU is detected but FFmpeg {{ shell_exec('/usr/bin/ffmpeg -version 2>/dev/null | head -1') ?? '' }} cannot initialise CUDA on this driver.<br>
<strong>Fix:</strong> Install a newer FFmpeg with CUDA 12+ support (e.g. <code>jellyfin-ffmpeg7</code>), then update the binary path below.<br>
Until then, video encoding will automatically fall back to CPU (libx264).
</div>
@endif
<div class="settings-section-body">
{{-- Detect button + GPU cards --}}
<div style="margin-bottom:20px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
<span style="font-size:13px;color:var(--text-2);">Available GPUs</span>
<button type="button" class="adm-btn adm-btn-sm" id="detectBtn" onclick="detectGpus()">
<i class="bi bi-arrow-repeat" id="detectIcon"></i> Detect GPUs
</button>
</div>
<div id="gpuCardsWrap">
@if(count($gpus))
@include('admin.partials.gpu-cards', ['gpus' => $gpus, 'selectedDevice' => $settings['gpu_device']])
@else
<div class="no-gpu-state">
<i class="bi bi-gpu-card"></i>
<p>No NVIDIA GPUs detected. Click "Detect GPUs" to scan.</p>
</div>
@endif
</div>
</div>
{{-- Enable GPU --}}
<div class="setting-row">
<div class="setting-label">
<strong>Enable GPU acceleration</strong>
<small>When enabled, video encoding uses the NVIDIA GPU. When disabled, falls back to CPU (libx264).</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<div class="toggle-switch">
<input type="checkbox" id="gpuEnabledInput" name="gpu_enabled_check"
{{ $settings['gpu_enabled'] === 'true' ? 'checked' : '' }}>
<div class="toggle-track"></div>
<div class="toggle-thumb"></div>
</div>
<span class="toggle-label" id="gpuEnabledLabel">
{{ $settings['gpu_enabled'] === 'true' ? 'Enabled' : 'Disabled' }}
</span>
</label>
<input type="hidden" name="gpu_enabled" id="gpuEnabledHidden"
value="{{ $settings['gpu_enabled'] }}">
</div>
</div>
{{-- GPU device (hidden input, controlled by card click) --}}
<input type="hidden" name="gpu_device" id="gpuDeviceInput" value="{{ $settings['gpu_device'] }}">
{{-- GPU Encoder --}}
<div class="setting-row" id="gpuEncoderRow">
<div class="setting-label">
<strong>Video encoder</strong>
<small>h264_nvenc is broadly compatible. hevc_nvenc produces smaller files (H.265) but requires compatible players. libx264 forces CPU encoding regardless of the toggle above.</small>
</div>
<div class="setting-control">
<div class="enc-grid">
@foreach([
['h264_nvenc', 'H.264 NVENC', 'GPU · max compatibility'],
['hevc_nvenc', 'H.265 NVENC', 'GPU · smaller files'],
['libx264', 'libx264', 'CPU · software fallback'],
] as [$val, $label, $desc])
<button type="button"
class="enc-card {{ $settings['gpu_encoder'] === $val ? 'selected' : '' }}"
data-encoder="{{ $val }}"
onclick="selectEncoder(this)">
<span class="enc-card-name">{{ $label }}</span>
<span class="enc-card-desc">{{ $desc }}</span>
</button>
@endforeach
</div>
<input type="hidden" name="gpu_encoder" id="gpuEncoderInput" value="{{ $settings['gpu_encoder'] }}">
</div>
</div>
{{-- Encoding preset --}}
<div class="setting-row" id="gpuPresetRow">
<div class="setting-label">
<strong>Encoding preset</strong>
<small>NVENC presets: p1 (fastest) → p7 (best quality). libx264 presets: fast / medium / slow. Preset only affects speed vs file size; quality is controlled by CQ/CRF.</small>
</div>
<div class="setting-control">
<select name="gpu_preset" class="adm-select-full" id="gpuPresetSelect">
<optgroup label="NVENC (GPU)">
@foreach(['p1','p2','p3','p4','p5','p6','p7'] as $p)
<option value="{{ $p }}" {{ $settings['gpu_preset'] === $p ? 'selected' : '' }}>
{{ $p }}{{ $p === 'p4' ? ' — recommended' : '' }}
</option>
@endforeach
</optgroup>
<optgroup label="libx264 (CPU)">
@foreach(['fast','medium','slow'] as $p)
<option value="{{ $p }}" {{ $settings['gpu_preset'] === $p ? 'selected' : '' }}>
{{ $p }}
</option>
@endforeach
</optgroup>
</select>
</div>
</div>
{{-- HW Accel --}}
<div class="setting-row" id="gpuHwaccelRow">
<div class="setting-label">
<strong>Hardware decode acceleration</strong>
<small>Use CUDA to decode the source video on the GPU before re-encoding, speeding up the pipeline. Disable if you see CUDA errors in the logs.</small>
</div>
<div class="setting-control">
<select name="gpu_hwaccel" class="adm-select-full">
<option value="cuda" {{ $settings['gpu_hwaccel'] === 'cuda' ? 'selected' : '' }}>cuda — GPU decode</option>
<option value="none" {{ $settings['gpu_hwaccel'] === 'none' ? 'selected' : '' }}>none — CPU decode</option>
</select>
</div>
</div>
{{-- FFmpeg binary path --}}
<div class="setting-row">
<div class="setting-label">
<strong>FFmpeg binary path</strong>
<small>
Absolute path to the <code>ffmpeg</code> executable.
Change this to use a newer build (e.g. <code>/usr/lib/jellyfin-ffmpeg/ffmpeg</code>)
that supports your GPU driver. Current: <code>{{ config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg') }}</code>
</small>
</div>
<div class="setting-control">
<input type="text" name="ffmpeg_binary" class="adm-select-full" style="height:auto;padding:8px 12px;"
value="{{ $settings['ffmpeg_binary'] }}"
placeholder="/usr/bin/ffmpeg">
</div>
</div>
</div>
</div>
{{-- ── Save ─────────────────────────────────────────────────── --}}
<div class="save-bar">
<a href="{{ route('admin.dashboard') }}" class="adm-btn">Cancel</a>
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-floppy"></i> Save Settings
</button>
</div>
</form>
@endsection
@section('scripts')
<script>
// ── GPU card selection ────────────────────────────────────────
function selectGpuCard(el) {
document.querySelectorAll('.gpu-card').forEach(c => c.classList.remove('selected'));
el.classList.add('selected');
document.getElementById('gpuDeviceInput').value = el.dataset.index;
}
// ── Encoder card selection ────────────────────────────────────
function selectEncoder(el) {
document.querySelectorAll('.enc-card').forEach(c => c.classList.remove('selected'));
el.classList.add('selected');
document.getElementById('gpuEncoderInput').value = el.dataset.encoder;
// Sync preset optgroup visibility hint
const isCpu = el.dataset.encoder === 'libx264';
document.getElementById('gpuPresetSelect').value = isCpu ? 'fast' : 'p4';
}
// ── GPU toggle ────────────────────────────────────────────────
const gpuToggle = document.getElementById('gpuEnabledInput');
const gpuHidden = document.getElementById('gpuEnabledHidden');
const gpuLabel = document.getElementById('gpuEnabledLabel');
function applyGpuToggle() {
const on = gpuToggle.checked;
gpuHidden.value = on ? 'true' : 'false';
gpuLabel.textContent = on ? 'Enabled' : 'Disabled';
}
gpuToggle.addEventListener('change', applyGpuToggle);
// ── Live GPU detect ───────────────────────────────────────────
async function detectGpus() {
const btn = document.getElementById('detectBtn');
const icon = document.getElementById('detectIcon');
btn.disabled = true;
icon.className = 'bi bi-arrow-repeat spin';
try {
const res = await fetch('{{ route('admin.settings.detect-gpu') }}');
const data = await res.json();
const gpus = data.gpus || [];
const wrap = document.getElementById('gpuCardsWrap');
const chip = document.getElementById('gpuStatusChip');
const selectedDevice = parseInt(document.getElementById('gpuDeviceInput').value);
if (gpus.length === 0) {
wrap.innerHTML = '<div class="no-gpu-state"><i class="bi bi-gpu-card"></i><p>No NVIDIA GPUs detected.</p></div>';
chip.innerHTML = '<span class="chip chip-red"><span class="chip-dot"></span> No GPU detected</span>';
} else {
chip.innerHTML = `<span class="chip chip-green"><span class="chip-dot"></span> ${gpus.length} GPU${gpus.length > 1 ? 's' : ''} detected</span>`;
wrap.innerHTML = buildGpuCards(gpus, selectedDevice);
}
} catch (e) {
console.error(e);
}
btn.disabled = false;
icon.className = 'bi bi-arrow-repeat';
}
function buildGpuCards(gpus, selectedDevice) {
if (!gpus.length) return '';
const grid = document.createElement('div');
grid.className = 'gpu-grid';
gpus.forEach(gpu => {
const used = gpu.mem_total - gpu.mem_free;
const usedPct = Math.round((used / gpu.mem_total) * 100);
const sel = gpu.index === selectedDevice;
const card = document.createElement('div');
card.className = 'gpu-card' + (sel ? ' selected' : '');
card.dataset.index = gpu.index;
card.onclick = function() { selectGpuCard(this); };
card.innerHTML = `
<div class="gpu-card-check">${sel ? '<i class="bi bi-check"></i>' : ''}</div>
<div class="gpu-card-name">${escHtml(gpu.name)}</div>
<div class="gpu-stat"><span>VRAM</span><span class="gpu-stat-val">${gpu.mem_total.toLocaleString()} MB</span></div>
<div class="gpu-stat"><span>Free</span><span class="gpu-stat-val">${gpu.mem_free.toLocaleString()} MB</span></div>
<div class="gpu-stat"><span>GPU Load</span><span class="gpu-stat-val">${gpu.util}%</span></div>
<div class="gpu-stat"><span>Temp</span><span class="gpu-stat-val">${gpu.temp} °C</span></div>
<div class="gpu-stat"><span>Driver</span><span class="gpu-stat-val">${escHtml(gpu.driver)}</span></div>
<div class="mem-bar-wrap">
<div class="mem-bar-track"><div class="mem-bar-fill" style="width:${usedPct}%"></div></div>
</div>`;
grid.appendChild(card);
});
return grid.outerHTML;
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
<style>
@keyframes spin { to { transform: rotate(360deg); } }
.spin { display: inline-block; animation: spin .6s linear infinite; }
</style>
@endsection
+170 -118
View File
@@ -1,135 +1,181 @@
@extends('admin.layout')
@section('title', 'User Management')
@section('page_title', 'User Management')
@section('title', 'Users')
@section('content')
<!-- Alerts -->
{{-- ── Page header ──────────────────────────────────────────────────── --}}
<div class="adm-page-header">
<h1 class="adm-page-title"><i class="bi bi-people"></i> Users</h1>
</div>
{{-- ── Alerts ───────────────────────────────────────────────────────── --}}
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<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="alert alert-danger alert-dismissible fade show" role="alert">
{{ session('error') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<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
<!-- Search & Filters -->
<div class="admin-card">
<form method="GET" action="{{ route('admin.users') }}" class="filter-form">
<div class="form-group">
<label for="search">Search</label>
<input type="text" name="search" id="search" class="form-control" placeholder="Search by name or email..." value="{{ request('search') }}">
</div>
<div class="form-group">
<label for="role">Role</label>
<select name="role" id="role" class="form-select">
{{-- ── Filter card ──────────────────────────────────────────────────── --}}
<div class="adm-card">
<div class="adm-card-body" style="padding:16px 20px;">
<form method="GET" action="{{ route('admin.users') }}" 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 name or email…"
value="{{ request('search') }}" autocomplete="off">
</div>
<select name="role" class="adm-select">
<option value="">All Roles</option>
<option value="user" {{ request('role') == 'user' ? 'selected' : '' }}>User</option>
<option value="admin" {{ request('role') == 'admin' ? 'selected' : '' }}>Admin</option>
<option value="super_admin" {{ request('role') == 'super_admin' ? 'selected' : '' }}>Super Admin</option>
<option value="user" {{ request('role') === 'user' ? 'selected' : '' }}>User</option>
<option value="admin" {{ request('role') === 'admin' ? 'selected' : '' }}>Admin</option>
<option value="super_admin" {{ request('role') === 'super_admin' ? 'selected' : '' }}>Super Admin</option>
</select>
</div>
<div class="form-group">
<label for="sort">Sort By</label>
<select name="sort" id="sort" class="form-select">
<option value="latest" {{ request('sort') == 'latest' ? 'selected' : '' }}>Latest First</option>
<option value="oldest" {{ request('sort') == 'oldest' ? 'selected' : '' }}>Oldest First</option>
<option value="name_asc" {{ request('sort') == 'name_asc' ? 'selected' : '' }}>Name (A-Z)</option>
<option value="name_desc" {{ request('sort') == 'name_desc' ? 'selected' : '' }}>Name (Z-A)</option>
<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="name_asc" {{ request('sort') === 'name_asc' ? 'selected' : '' }}>Name A–Z</option>
<option value="name_desc" {{ request('sort') === 'name_desc' ? 'selected' : '' }}>Name Z–A</option>
</select>
</div>
<div class="form-group">
<label>&nbsp;</label>
<button type="submit" class="btn btn-primary">
<i class="bi bi-search"></i> Filter
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-funnel"></i> Filter
</button>
<a href="{{ route('admin.users') }}" class="btn btn-outline-light">
<i class="bi bi-x-circle"></i> Clear
@if(request()->hasAny(['search','role','sort']))
<a href="{{ route('admin.users') }}" class="adm-btn">
<i class="bi bi-x-lg"></i> Clear
</a>
</div>
</form>
@endif
</form>
</div>
</div>
<!-- Users Table -->
<div class="admin-card">
<div class="admin-card-header">
All Users ({{ $users->count() }})
{{-- ── Users table ──────────────────────────────────────────────────── --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title">
<i class="bi bi-people"></i>
All Users
<span class="adm-badge adm-badge-user">{{ $users->total() ?? $users->count() }}</span>
</div>
</div>
<div class="table-responsive">
<table class="admin-table">
<div class="adm-table-wrap">
<table class="adm-table">
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Role</th>
<th>Verified</th>
<th>Videos</th>
<th>Joined</th>
<th>Actions</th>
<th style="width:80px; text-align:right;">Actions</th>
</tr>
</thead>
<tbody>
@forelse($users as $user)
<tr>
{{-- User cell --}}
<td>
<div class="d-flex align-items-center gap-2">
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="user-avatar">
<div class="adm-user-cell">
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}">
<div>
<div>{{ $user->name }}</div>
@if($user->id === auth()->id())
<small class="text-info">(You)</small>
@endif
<div style="display:flex;align-items:center;gap:4px;">
<span class="adm-user-cell-name">{{ $user->name }}</span>
@if($user->id === auth()->id())
<span class="adm-user-cell-you">you</span>
@endif
</div>
<div class="adm-user-cell-email">{{ $user->email }}</div>
</div>
</div>
</td>
<td>{{ $user->email }}</td>
{{-- Role --}}
<td>
@if($user->role === 'super_admin')
<span class="badge-role badge-super-admin">Super Admin</span>
<span class="adm-badge adm-badge-superadmin"><i class="bi bi-shield-fill"></i> Super Admin</span>
@elseif($user->role === 'admin')
<span class="badge-role badge-admin">Admin</span>
<span class="adm-badge adm-badge-admin"><i class="bi bi-person-badge"></i> Admin</span>
@else
<span class="badge-role badge-user">User</span>
<span class="adm-badge adm-badge-user"><i class="bi bi-person"></i> User</span>
@endif
</td>
{{-- Verified --}}
<td>
<a href="{{ route('channel', $user->id) }}" target="_blank" class="text-decoration-none">
{{ $user->videos->count() }} videos
@if($user->email_verified_at)
<span class="adm-badge adm-badge-verified"><i class="bi bi-check-circle-fill"></i> Verified</span>
@else
<span class="adm-badge adm-badge-unverified"><i class="bi bi-clock"></i> Pending</span>
@endif
</td>
{{-- Videos --}}
<td>
<a href="{{ route('channel', $user->channel) }}" target="_blank"
class="text-dim" style="text-decoration:none; font-size:13px;">
{{ $user->videos->count() }}
<i class="bi bi-box-arrow-up-right" style="font-size:10px; opacity:.5; margin-left:2px;"></i>
</a>
</td>
<td>{{ $user->created_at->format('M d, Y') }}</td>
{{-- Joined --}}
<td class="text-muted-sm">{{ $user->created_at->format('M d, Y') }}</td>
{{-- Actions --}}
<td>
<div class="dropdown">
<button class="btn btn-sm btn-outline-light dropdown-toggle" data-bs-toggle="dropdown">
<i class="bi bi-gear"></i>
<div class="adm-row-actions" style="justify-content:flex-end;">
@if(!$user->email_verified_at)
<form method="POST" action="{{ route('admin.users.verify', $user->id) }}" style="display:inline;">
@csrf
<button type="submit" class="adm-btn adm-btn-sm adm-btn-verify" title="Manually verify account">
<i class="bi bi-patch-check-fill"></i>
</button>
</form>
@endif
@if($user->id !== auth()->id() && !$user->isSuperAdmin())
<form method="POST" action="{{ route('admin.users.impersonate', $user->id) }}" style="display:inline;">
@csrf
<button type="submit" class="adm-btn adm-btn-sm adm-btn-impersonate" title="Impersonate user">
<i class="bi bi-person-fill-gear"></i>
</button>
</form>
@endif
<a href="{{ route('admin.users.edit', $user->id) }}"
class="adm-btn adm-btn-sm" title="Edit user">
<i class="bi bi-pencil"></i>
</a>
@if($user->id !== auth()->id())
<button type="button"
class="adm-btn adm-btn-sm adm-btn-danger"
title="Delete user"
onclick="openDeleteDialog({{ $user->id }}, '{{ addslashes($user->name) }}')">
<i class="bi bi-trash"></i>
</button>
<ul class="dropdown-menu dropdown-menu-dark">
<li>
<a class="dropdown-item" href="{{ route('admin.users.edit', $user->id) }}">
<i class="bi bi-pencil"></i> Edit
</a>
</li>
@if($user->id !== auth()->id())
<li>
<button class="dropdown-item text-danger" onclick="confirmDeleteUser({{ $user->id }}, '{{ $user->name }}')">
<i class="bi bi-trash"></i> Delete
</button>
</li>
@endif
</ul>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="text-center text-secondary py-4">
No users found
<td colspan="6">
<div class="empty-state">
<i class="bi bi-people"></i>
<p>No users found</p>
</div>
</td>
</tr>
@endforelse
@@ -137,40 +183,42 @@ All Users ({{ $users->count() }})
</table>
</div>
<!-- Pagination -->
<div class="d-flex justify-content-center">
{{ $users->links() }}
{{-- Pagination --}}
@if($users instanceof \Illuminate\Pagination\LengthAwarePaginator && $users->hasPages())
<div style="padding:16px 20px; border-top:1px solid var(--border);">
{{ $users->onEachSide(1)->links() }}
</div>
@endif
</div>
<!-- Delete User Modal -->
<div class="modal fade" id="deleteUserModal" tabindex="-1" aria-labelledby="deleteUserModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content" style="background: #1a1a1a; border: 1px solid #3f3f3f; border-radius: 12px;">
<div class="modal-header" style="border-bottom: 1px solid #3f3f3f; padding: 20px 24px;">
<h5 class="modal-title" id="deleteUserModalLabel" style="color: #fff; font-weight: 600;">
<i class="bi bi-exclamation-triangle-fill text-danger me-2"></i>
Delete User
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
{{-- ── 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 User
</div>
<div class="modal-body" style="padding: 24px;">
<p>Are you sure you want to delete this user? This action cannot be undone.</p>
<p><strong>User:</strong> <span id="deleteUserName"></span></p>
<div class="alert alert-warning">
<i class="bi bi-info-circle me-2"></i>
This will also delete all videos uploaded by this user.
</div>
</div>
<div class="modal-footer" style="border-top: 1px solid #3f3f3f; padding: 16px 24px;">
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>
<form id="deleteUserForm" method="POST">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger">Delete User</button>
</form>
<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="dlgUserName"></strong>.</p>
<div class="adm-dialog-warning">
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
All videos uploaded by this user will also be deleted. This cannot be undone.
</div>
</div>
<div class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDeleteDialog()">Cancel</button>
<form id="deleteForm" method="POST">
@csrf @method('DELETE')
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
<i class="bi bi-trash"></i> Delete User
</button>
</form>
</div>
</div>
</div>
@@ -178,15 +226,19 @@ All Users ({{ $users->count() }})
@section('scripts')
<script>
let currentDeleteUserId = null;
function confirmDeleteUser(userId, userName) {
currentDeleteUserId = userId;
document.getElementById('deleteUserName').textContent = userName;
document.getElementById('deleteUserForm').action = '/admin/users/' + userId;
const modal = new bootstrap.Modal(document.getElementById('deleteUserModal'));
modal.show();
}
function openDeleteDialog(userId, userName) {
document.getElementById('dlgUserName').textContent = userName;
document.getElementById('deleteForm').action = '/admin/users/' + userId;
document.getElementById('deleteDialog').classList.add('open');
}
function closeDeleteDialog() {
document.getElementById('deleteDialog').classList.remove('open');
}
document.getElementById('deleteDialog').addEventListener('click', function(e) {
if (e.target === this) closeDeleteDialog();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeDeleteDialog();
});
</script>
@endsection
@@ -0,0 +1,590 @@
@extends('admin.layout')
@section('title', 'Analytics — ' . $video->title)
@section('page_title', 'Video Analytics')
@php
function flagEmoji(string $code): string {
$chars = '';
foreach (str_split(strtoupper($code)) as $c) {
$chars .= mb_chr(0x1F1E6 + ord($c) - ord('A'));
}
return $chars;
}
@endphp
@section('content')
<style>
.analytics-back {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
text-decoration: none;
font-size: 13px;
margin-bottom: 18px;
transition: color .15s;
}
.analytics-back:hover { color: var(--text-primary); }
/* Video info header */
.video-info-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px 24px;
display: flex;
gap: 20px;
align-items: flex-start;
margin-bottom: 24px;
flex-wrap: wrap;
}
.video-info-thumb {
width: 140px;
height: 88px;
border-radius: 8px;
object-fit: cover;
background: #1a1a1a;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 28px;
}
.video-info-thumb img { width: 100%; height: 100%; object-fit: cover; border-radius: 8px; }
.video-info-body { flex: 1; min-width: 200px; }
.video-info-title { font-size: 18px; font-weight: 700; margin-bottom: 6px; line-height: 1.3; }
.video-info-meta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; }
.video-info-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
padding: 3px 10px;
border-radius: 20px;
background: #2a2a2a;
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.video-info-badge.red { color: var(--brand-red); border-color: var(--brand-red); }
.video-info-actions { display: flex; gap: 10px; align-items: center; margin-left: auto; flex-shrink: 0; }
/* KPI cards */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 14px;
margin-bottom: 24px;
}
.kpi-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 18px 16px;
text-align: center;
}
.kpi-card .kpi-icon {
font-size: 22px;
margin-bottom: 8px;
color: var(--text-secondary);
}
.kpi-card .kpi-value {
font-size: 26px;
font-weight: 700;
line-height: 1;
margin-bottom: 4px;
}
.kpi-card .kpi-label {
font-size: 12px;
color: var(--text-secondary);
}
.kpi-card.red .kpi-value { color: var(--brand-red); }
.kpi-card.green .kpi-value { color: #4caf50; }
.kpi-card.blue .kpi-value { color: #2196f3; }
.kpi-card.orange .kpi-value { color: #ff9800; }
.kpi-card.purple .kpi-value { color: #9c27b0; }
.kpi-card.teal .kpi-value { color: #009688; }
/* Panel */
.an-panel {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px 22px;
margin-bottom: 24px;
}
.an-panel-title {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: .6px;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 7px;
}
.chart-wrap { position: relative; height: 220px; }
.chart-wrap-tall { position: relative; height: 280px; }
/* Country list */
.country-row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
border-bottom: 1px solid rgba(255,255,255,.04);
}
.country-row:last-child { border-bottom: none; }
.country-rank { font-size: 12px; color: var(--text-secondary); width: 18px; text-align: right; flex-shrink: 0; }
.country-flag { font-size: 18px; line-height: 1; flex-shrink: 0; }
.country-name { flex: 1; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.country-bar-wrap { width: 90px; flex-shrink: 0; }
.country-bar { height: 5px; border-radius: 3px; background: var(--brand-red); min-width: 3px; }
.country-count { font-size: 13px; font-weight: 600; flex-shrink: 0; width: 36px; text-align: right; }
/* Age & gender grid */
.analytics-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
@media (max-width: 768px) { .analytics-2col { grid-template-columns: 1fr; } }
/* Gender placeholder */
.gender-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 160px;
color: var(--text-secondary);
text-align: center;
gap: 10px;
}
.gender-placeholder i { font-size: 32px; opacity: .4; }
.gender-placeholder p { font-size: 13px; line-height: 1.5; max-width: 260px; margin: 0; }
/* Recent viewers table */
.viewer-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.viewer-table th {
font-size: 11px;
text-transform: uppercase;
letter-spacing: .5px;
color: var(--text-secondary);
padding: 0 12px 10px;
text-align: left;
border-bottom: 1px solid var(--border-color);
font-weight: 500;
}
.viewer-table td { padding: 10px 12px; border-bottom: 1px solid rgba(255,255,255,.04); vertical-align: middle; }
.viewer-table tr:last-child td { border-bottom: none; }
.viewer-avatar {
width: 30px; height: 30px; border-radius: 50%; object-fit: cover;
background: #2a2a2a; display: inline-flex; align-items: center;
justify-content: center; font-size: 13px; color: var(--text-secondary);
}
.viewer-avatar img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
.guest-badge { font-size: 10px; padding: 2px 7px; border-radius: 10px; background: #252525; color: #888; border: 1px solid #333; }
.auth-badge { font-size: 10px; padding: 2px 7px; border-radius: 10px; background: rgba(33,150,243,.12); color: #64b5f6; border: 1px solid rgba(33,150,243,.25); }
</style>
<!-- Back -->
<a href="{{ route('admin.videos') }}" class="analytics-back">
<i class="bi bi-arrow-left"></i> Back to Videos
</a>
<!-- Video info header -->
<div class="video-info-card">
<div class="video-info-thumb">
@if($video->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}">
@else
<i class="bi bi-play-circle"></i>
@endif
</div>
<div class="video-info-body">
<div class="video-info-title">{{ $video->title }}</div>
<div style="font-size:13px; color:var(--text-secondary); margin-bottom:6px;">
by <a href="{{ route('channel', $video->user->channel) }}" target="_blank" style="color:var(--text-secondary);">{{ $video->user->name }}</a>
</div>
<div class="video-info-meta">
<span class="video-info-badge">
<i class="bi bi-calendar3"></i>
{{ $video->created_at->format('M d, Y') }}
</span>
<span class="video-info-badge">
<i class="bi bi-clock"></i>
{{ $video->formatted_duration ?? '—' }}
</span>
<span class="video-info-badge">
<i class="bi bi-tag"></i>
{{ ucfirst($video->type) }}
</span>
@php
$statusColors = ['ready'=>'#4caf50','processing'=>'#ff9800','pending'=>'#888','failed'=>'#f44336'];
$sc = $statusColors[$video->status] ?? '#888';
@endphp
<span class="video-info-badge" style="color:{{ $sc }}; border-color:{{ $sc }};">
<i class="bi bi-circle-fill" style="font-size:7px;"></i>
{{ ucfirst($video->status) }}
</span>
<span class="video-info-badge">
<i class="bi bi-eye"></i>
{{ ucfirst($video->visibility) }}
</span>
</div>
</div>
<div class="video-info-actions">
<a href="{{ route('videos.show', $video) }}" target="_blank" class="btn btn-sm btn-outline-light">
<i class="bi bi-play-circle"></i> Watch
</a>
<a href="{{ route('admin.videos.edit', $video) }}" class="btn btn-sm btn-outline-light">
<i class="bi bi-pencil"></i> Edit
</a>
</div>
</div>
<!-- KPIs -->
<div class="kpi-grid">
<div class="kpi-card red">
<div class="kpi-icon"><i class="bi bi-eye"></i></div>
<div class="kpi-value">{{ number_format($totalViews) }}</div>
<div class="kpi-label">Total Views</div>
<div style="font-size:10px; color:var(--text-secondary); margin-top:3px;">all watch events</div>
</div>
<div class="kpi-card blue">
<div class="kpi-icon"><i class="bi bi-people"></i></div>
<div class="kpi-value">{{ number_format($totalUniqueViewers) }}</div>
<div class="kpi-label">Unique Viewers</div>
<div style="font-size:10px; color:var(--text-secondary); margin-top:3px;">each person once</div>
</div>
<div class="kpi-card green">
<div class="kpi-icon"><i class="bi bi-heart"></i></div>
<div class="kpi-value">{{ number_format($totalLikes) }}</div>
<div class="kpi-label">Likes</div>
</div>
<div class="kpi-card orange">
<div class="kpi-icon"><i class="bi bi-chat"></i></div>
<div class="kpi-value">{{ number_format($totalComments) }}</div>
<div class="kpi-label">Comments</div>
</div>
<div class="kpi-card purple">
<div class="kpi-icon"><i class="bi bi-person-check"></i></div>
<div class="kpi-value">{{ number_format($authViewers) }}</div>
<div class="kpi-label">Logged-in</div>
</div>
<div class="kpi-card teal">
<div class="kpi-icon"><i class="bi bi-incognito"></i></div>
<div class="kpi-value">{{ number_format($guestViewers) }}</div>
<div class="kpi-label">Guests</div>
</div>
</div>
<!-- Views over time -->
<div class="an-panel">
<div class="an-panel-title"><i class="bi bi-graph-up"></i> View Events – Last 30 Days <span style="font-weight:400; font-size:11px; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(total plays, not unique)</span></div>
<div class="chart-wrap">
<canvas id="dailyChart"></canvas>
</div>
</div>
<!-- Countries -->
<div class="analytics-2col">
<!-- Left: list -->
<div class="an-panel" style="margin-bottom:0;">
<div class="an-panel-title"><i class="bi bi-globe2"></i> Viewers by Country <span style="font-weight:400; font-size:11px; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(unique viewers)</span></div>
@php $maxCountry = $viewsByCountry->first()->total ?? 1; @endphp
@forelse($viewsByCountry as $i => $row)
<div class="country-row">
<span class="country-rank">{{ $i + 1 }}</span>
<span class="country-flag">{{ flagEmoji($row->country) }}</span>
<span class="country-name">{{ $row->country_name ?? $row->country }}</span>
<div class="country-bar-wrap">
<div class="country-bar" style="width:{{ round(($row->total / $maxCountry) * 100) }}%"></div>
</div>
<span class="country-count">{{ number_format($row->total) }}</span>
</div>
@empty
<div style="text-align:center; padding:40px 0; color:var(--text-secondary); font-size:13px;">
No country data yet — views are still being collected.
</div>
@endforelse
</div>
<!-- Right: chart -->
<div class="an-panel" style="margin-bottom:0;">
<div class="an-panel-title"><i class="bi bi-bar-chart-horizontal"></i> Country Distribution</div>
@if($viewsByCountry->isNotEmpty())
<div class="chart-wrap-tall">
<canvas id="countryChart"></canvas>
</div>
@else
<div style="text-align:center; padding:40px 0; color:var(--text-secondary); font-size:13px;">
Chart will appear once geo data is collected.
</div>
@endif
</div>
</div>
<div style="margin-bottom:24px;"></div>
<!-- Age groups + Gender -->
<div class="analytics-2col">
<!-- Age -->
<div class="an-panel" style="margin-bottom:0;">
<div class="an-panel-title"><i class="bi bi-person-badge"></i> Age Groups <span style="font-weight:400; font-size:11px; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(unique viewers)</span></div>
@php $totalAge = array_sum($ageGroups); @endphp
@if($totalAge > 0)
<div class="chart-wrap">
<canvas id="ageChart"></canvas>
</div>
<div style="margin-top:14px; display:grid; grid-template-columns: repeat(auto-fill, minmax(100px,1fr)); gap:6px;">
@foreach($ageGroups as $label => $count)
@if($count > 0)
<div style="background:#1a1a1a; border-radius:8px; padding:8px 10px; text-align:center;">
<div style="font-size:15px; font-weight:700;">{{ number_format($count) }}</div>
<div style="font-size:11px; color:var(--text-secondary);">{{ $label }}</div>
</div>
@endif
@endforeach
</div>
@else
<div style="text-align:center; padding:40px 0; color:var(--text-secondary); font-size:13px;">
Age data requires users to set their birthday in profile settings.
</div>
@endif
</div>
<!-- Gender -->
<div class="an-panel" style="margin-bottom:0;">
<div class="an-panel-title"><i class="bi bi-gender-ambiguous"></i> Gender Split <span style="font-weight:400; font-size:11px; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(unique viewers)</span></div>
@php $genderTotal = array_sum($genderCounts); @endphp
@if($genderTotal > 0)
<div style="display:flex; align-items:center; gap:24px; flex-wrap:wrap;">
<div style="position:relative; width:160px; height:160px; flex-shrink:0; margin: 0 auto;">
<canvas id="genderChart"></canvas>
</div>
<div style="flex:1; min-width:120px;">
@php
$genderIcons = ['Male' => 'bi-gender-male', 'Female' => 'bi-gender-female', 'Prefer not to say' => 'bi-dash-circle'];
$genderColors = ['Male' => '#2196f3', 'Female' => '#e91e63', 'Prefer not to say' => '#888'];
@endphp
@foreach($genderCounts as $label => $count)
@if($count > 0)
<div style="display:flex; align-items:center; gap:9px; margin-bottom:10px;">
<span style="width:10px; height:10px; border-radius:50%; background:{{ $genderColors[$label] }}; flex-shrink:0;"></span>
<i class="bi {{ $genderIcons[$label] }}" style="color:{{ $genderColors[$label] }};"></i>
<span style="flex:1; font-size:13px;">{{ $label }}</span>
<strong style="font-size:14px;">{{ number_format($count) }}</strong>
<span style="font-size:12px; color:var(--text-secondary);">{{ round(($count / $genderTotal) * 100) }}%</span>
</div>
@endif
@endforeach
<div style="font-size:11px; color:var(--text-secondary); margin-top:8px;">Based on {{ number_format($genderTotal) }} logged-in viewer{{ $genderTotal !== 1 ? 's' : '' }} who set their gender.</div>
</div>
</div>
@else
<div class="gender-placeholder">
<i class="bi bi-gender-ambiguous"></i>
<p>No gender data yet. Viewers can set their gender in profile settings.</p>
</div>
@endif
</div>
</div>
<div style="margin-bottom:24px;"></div>
<!-- Recent viewers -->
<div class="an-panel">
<div class="an-panel-title"><i class="bi bi-clock-history"></i> Recent View Events <span style="font-weight:400; font-size:11px; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(last 20 individual plays)</span></div>
@if($recentViews->isNotEmpty())
<div style="overflow-x:auto;">
<table class="viewer-table">
<thead>
<tr>
<th>Viewer</th>
<th>Country</th>
<th>IP Address</th>
<th>Type</th>
<th>Watched At</th>
</tr>
</thead>
<tbody>
@foreach($recentViews as $view)
<tr>
<td>
<div style="display:flex; align-items:center; gap:10px;">
<div class="viewer-avatar">
@if($view->viewer_avatar)
<img src="{{ asset('storage/avatars/' . $view->viewer_avatar) }}" alt="">
@else
<i class="bi bi-person"></i>
@endif
</div>
<span>{{ $view->viewer_name ?? 'Guest' }}</span>
</div>
</td>
<td>
@if($view->country)
<span style="font-size:16px;">{{ flagEmoji($view->country) }}</span>
<span style="font-size:13px; margin-left:4px;">{{ $view->country_name ?? $view->country }}</span>
@else
<span style="color:var(--text-secondary); font-size:12px;">Unknown</span>
@endif
</td>
<td style="font-family:monospace; font-size:12px; color:var(--text-secondary);">
{{ $view->ip_address ?? '—' }}
</td>
<td>
@if($view->user_id)
<span class="auth-badge"><i class="bi bi-person-check"></i> Logged in</span>
@else
<span class="guest-badge"><i class="bi bi-incognito"></i> Guest</span>
@endif
</td>
<td style="color:var(--text-secondary); font-size:12px; white-space:nowrap;">
{{ \Carbon\Carbon::parse($view->watched_at)->diffForHumans() }}
<br><span style="font-size:11px; opacity:.6;">{{ \Carbon\Carbon::parse($view->watched_at)->format('M d, Y H:i') }}</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div style="text-align:center; padding:40px 0; color:var(--text-secondary); font-size:13px;">
No views recorded yet.
</div>
@endif
</div>
@endsection
@section('scripts')
<script>
// ── Shared colours ──────────────────────────────────────────────────────────
const RED = 'rgba(230,30,30,1)';
const RED_BG = 'rgba(230,30,30,0.15)';
const GRID = 'rgba(255,255,255,0.06)';
const TICK = '#888';
Chart.defaults.color = TICK;
Chart.defaults.borderColor = GRID;
// ── Views per day ───────────────────────────────────────────────────────────
new Chart(document.getElementById('dailyChart'), {
type: 'line',
data: {
labels: {!! json_encode($dailyLabels) !!},
datasets: [{
label: 'Views',
data: {!! json_encode($dailyViews) !!},
borderColor: RED,
backgroundColor: RED_BG,
borderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,
pointBackgroundColor: RED,
fill: true,
tension: 0.3,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: GRID }, ticks: { color: TICK, maxTicksLimit: 10 } },
y: { grid: { color: GRID }, ticks: { color: TICK, precision: 0 }, beginAtZero: true }
}
}
});
// ── Country chart ───────────────────────────────────────────────────────────
@if($viewsByCountry->isNotEmpty())
const countryLabels = {!! json_encode($viewsByCountry->map(fn($r) => ($r->country_name ?? $r->country))->values()) !!};
const countryData = {!! json_encode($viewsByCountry->pluck('total')->values()) !!};
const countryMax = Math.max(...countryData);
new Chart(document.getElementById('countryChart'), {
type: 'bar',
data: {
labels: countryLabels,
datasets: [{
data: countryData,
backgroundColor: countryData.map((v, i) => `rgba(230,30,30,${0.9 - (i / countryData.length) * 0.55})`),
borderRadius: 4,
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: GRID }, ticks: { color: TICK, precision: 0 }, beginAtZero: true },
y: { grid: { color: 'transparent' }, ticks: { color: TICK } }
}
}
});
@endif
// ── Age groups ──────────────────────────────────────────────────────────────
@php
$ageLabels = array_keys($ageGroups);
$ageValues = array_values($ageGroups);
$ageColors = ['rgba(33,150,243,0.85)', 'rgba(76,175,80,0.85)', 'rgba(255,152,0,0.85)',
'rgba(233,30,99,0.85)', 'rgba(156,39,176,0.85)', 'rgba(0,188,212,0.85)', 'rgba(120,120,120,0.6)'];
@endphp
@if(array_sum($ageGroups) > 0)
new Chart(document.getElementById('ageChart'), {
type: 'bar',
data: {
labels: {!! json_encode($ageLabels) !!},
datasets: [{
data: {!! json_encode($ageValues) !!},
backgroundColor: {!! json_encode($ageColors) !!},
borderRadius: 5,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: 'transparent' }, ticks: { color: TICK } },
y: { grid: { color: GRID }, ticks: { color: TICK, precision: 0 }, beginAtZero: true }
}
}
});
@endif
// ── Gender donut ─────────────────────────────────────────────────────────────
@php $genderTotal = array_sum($genderCounts); @endphp
@if($genderTotal > 0)
new Chart(document.getElementById('genderChart'), {
type: 'doughnut',
data: {
labels: {!! json_encode(array_keys($genderCounts)) !!},
datasets: [{
data: {!! json_encode(array_values($genderCounts)) !!},
backgroundColor: ['#2196f3', '#e91e63', '#555'],
borderWidth: 0,
hoverOffset: 6,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => ` ${ctx.label}: ${ctx.parsed} (${Math.round(ctx.parsed / {{ $genderTotal }} * 100)}%)`
}
}
}
}
});
@endif
</script>
@endsection
+205 -157
View File
@@ -1,88 +1,96 @@
@extends('admin.layout')
@section('title', 'Video Management')
@section('page_title', 'Video Management')
@section('title', 'Videos')
@section('content')
<!-- Alerts -->
{{-- ── 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="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<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="alert alert-danger alert-dismissible fade show" role="alert">
{{ session('error') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<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
<!-- Search & Filters -->
<div class="admin-card">
<form method="GET" action="{{ route('admin.videos') }}" class="filter-form">
<div class="form-group">
<label for="search">Search</label>
<input type="text" name="search" id="search" class="form-control" placeholder="Search by title or description..." value="{{ request('search') }}">
</div>
<div class="form-group">
<label for="status">Status</label>
<select name="status" id="status" class="form-select">
{{-- ── 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>
<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>
</div>
<div class="form-group">
<label for="visibility">Visibility</label>
<select name="visibility" id="visibility" class="form-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>
<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>
</div>
<div class="form-group">
<label for="type">Type</label>
<select name="type" id="type" class="form-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>
<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>
</div>
<div class="form-group">
<label for="sort">Sort By</label>
<select name="sort" id="sort" class="form-select">
<option value="latest" {{ request('sort') == 'latest' ? 'selected' : '' }}>Latest 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 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>
</div>
<div class="form-group">
<label>&nbsp;</label>
<button type="submit" class="btn btn-primary">
<i class="bi bi-search"></i> Filter
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-funnel"></i> Filter
</button>
<a href="{{ route('admin.videos') }}" class="btn btn-outline-light">
<i class="bi bi-x-circle"></i> Clear
@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>
</div>
</form>
@endif
</form>
</div>
</div>
<!-- Videos Table -->
<div class="admin-card">
<div class="admin-card-header">
All Videos ({{ $videos->count() }})
{{-- ── 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="table-responsive">
<table class="admin-table">
<div class="adm-table-wrap">
<table class="adm-table">
<thead>
<tr>
<th>Video</th>
@@ -93,97 +101,128 @@ All Videos ({{ $videos->count() }})
<th>Views</th>
<th>Likes</th>
<th>Uploaded</th>
<th>Actions</th>
<th style="width:110px; text-align:right;">Actions</th>
</tr>
</thead>
<tbody>
@forelse($videos as $video)
<tr>
{{-- Thumbnail + title --}}
<td>
<div class="d-flex align-items-center gap-2">
<div style="display:flex; align-items:center; gap:12px;">
@if($video->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}" style="width: 80px; height: 50px; object-fit: cover; border-radius: 4px;">
<img src="{{ asset('storage/thumbnails/' . $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: 80px; height: 50px; background: #333; border-radius: 4px; display: flex; align-items: center; justify-content: center;">
<i class="bi bi-play-circle text-secondary"></i>
<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="max-width: 200px;">
<div style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 500;">{{ $video->title }}</div>
<small class="text-secondary">{{ Str::limit($video->description, 50) }}</small>
<div style="min-width:0;">
<div style="font-weight:500;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;">
{{ $video->title }}
</div>
@if($video->description)
<div style="font-size:11px;color:var(--text-2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;">
{{ Str::limit($video->description, 55) }}
</div>
@endif
</div>
</div>
</td>
{{-- Owner --}}
<td>
<a href="{{ route('channel', $video->user->id) }}" target="_blank" class="text-decoration-none">
<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>
@switch($video->status)
@case('ready')
<span class="badge-status badge-ready">Ready</span>
@break
@case('processing')
<span class="badge-status badge-processing">Processing</span>
@break
@case('pending')
<span class="badge-status badge-pending">Pending</span>
@break
@case('failed')
<span class="badge-status badge-failed">Failed</span>
@break
@endswitch
@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>
@switch($video->visibility)
@case('public')
<span class="badge-status badge-public">Public</span>
@break
@case('unlisted')
<span class="badge-status badge-unlisted">Unlisted</span>
@break
@case('private')
<span class="badge-status badge-private">Private</span>
@break
@endswitch
@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>
<span class="text-capitalize">{{ $video->type }}</span>
@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>
<td>{{ number_format(\DB::table('video_views')->where('video_id', $video->id)->count()) }}</td>
<td>{{ number_format(\DB::table('video_likes')->where('video_id', $video->id)->count()) }}</td>
<td>{{ $video->created_at->format('M d, Y') }}</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>
{{-- Date --}}
<td class="text-muted-sm">{{ $video->created_at->format('M d, Y') }}</td>
{{-- Actions --}}
<td>
<div class="dropdown">
<button class="btn btn-sm btn-outline-light dropdown-toggle" data-bs-toggle="dropdown">
<i class="bi bi-gear"></i>
<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>
<ul class="dropdown-menu dropdown-menu-dark">
<li>
<a class="dropdown-item" href="{{ route('videos.show', $video->id) }}" target="_blank">
<i class="bi bi-play-circle"></i> View
</a>
</li>
<li>
<a class="dropdown-item" href="{{ route('admin.videos.edit', $video->id) }}">
<i class="bi bi-pencil"></i> Edit
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<button class="dropdown-item text-danger" onclick="confirmDeleteVideo({{ $video->id }}, '{{ $video->title }}')">
<i class="bi bi-trash"></i> Delete
</button>
</li>
</ul>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="9" class="text-center text-secondary py-4">
No videos found
<td colspan="9">
<div class="empty-state">
<i class="bi bi-play-circle"></i>
<p>No videos found</p>
</div>
</td>
</tr>
@endforelse
@@ -191,40 +230,42 @@ All Videos ({{ $videos->count() }})
</table>
</div>
<!-- Pagination -->
<div class="d-flex justify-content-center">
{{ $videos->links() }}
{{-- 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 Video Modal -->
<div class="modal fade" id="deleteVideoModal" tabindex="-1" aria-labelledby="deleteVideoModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content" style="background: #1a1a1a; border: 1px solid #3f3f3f; border-radius: 12px;">
<div class="modal-header" style="border-bottom: 1px solid #3f3f3f; padding: 20px 24px;">
<h5 class="modal-title" id="deleteVideoModalLabel" style="color: #fff; font-weight: 600;">
<i class="bi bi-exclamation-triangle-fill text-danger me-2"></i>
Delete Video
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
{{-- ── 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>
<div class="modal-body" style="padding: 24px;">
<p>Are you sure you want to delete this video? This action cannot be undone.</p>
<p><strong>Video:</strong> <span id="deleteVideoTitle"></span></p>
<div class="alert alert-warning">
<i class="bi bi-info-circle me-2"></i>
This will also delete all likes and views associated with this video.
</div>
</div>
<div class="modal-footer" style="border-top: 1px solid #3f3f3f; padding: 16px 24px;">
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>
<form id="deleteVideoForm" method="POST">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger">Delete Video</button>
</form>
<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>
</div>
<div class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDeleteDialog()">Cancel</button>
<form id="deleteForm" method="POST">
@csrf @method('DELETE')
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
<i class="bi bi-trash"></i> Delete Video
</button>
</form>
</div>
</div>
</div>
@@ -232,12 +273,19 @@ All Videos ({{ $videos->count() }})
@section('scripts')
<script>
function confirmDeleteVideo(videoId, videoTitle) {
document.getElementById('deleteVideoTitle').textContent = videoTitle;
document.getElementById('deleteVideoForm').action = '/admin/videos/' + videoId;
const modal = new bootstrap.Modal(document.getElementById('deleteVideoModal'));
modal.show();
}
function openDeleteDialog(videoId, videoTitle) {
document.getElementById('dlgVideoTitle').textContent = videoTitle;
document.getElementById('deleteForm').action = '/admin/videos/' + videoId;
document.getElementById('deleteDialog').classList.add('open');
}
function closeDeleteDialog() {
document.getElementById('deleteDialog').classList.remove('open');
}
document.getElementById('deleteDialog').addEventListener('click', function(e) {
if (e.target === this) closeDeleteDialog();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeDeleteDialog();
});
</script>
@endsection