- 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 <noreply@anthropic.com>
1051 lines
56 KiB
PHP
1051 lines
56 KiB
PHP
@extends('admin.layout')
|
|
|
|
@section('title', 'Admin Dashboard')
|
|
@section('page_title', 'Dashboard')
|
|
|
|
@section('extra_styles')
|
|
<style>
|
|
/* ── Stat cards ── */
|
|
.stat-card {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 14px;
|
|
padding: 20px 22px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
position: relative;
|
|
overflow: hidden;
|
|
transition: border-color .2s;
|
|
}
|
|
.stat-card:hover { border-color: #444; }
|
|
.stat-card-accent {
|
|
position: absolute; top: 0; left: 0; right: 0; height: 3px; border-radius: 14px 14px 0 0;
|
|
}
|
|
.stat-card-top { display: flex; align-items: center; justify-content: space-between; }
|
|
.stat-icon {
|
|
width: 42px; height: 42px; border-radius: 10px;
|
|
display: flex; align-items: center; justify-content: center; font-size: 20px;
|
|
}
|
|
.stat-value { font-size: 28px; font-weight: 700; color: var(--text-primary); line-height: 1; }
|
|
.stat-label { font-size: 13px; color: var(--text-secondary); font-weight: 500; }
|
|
.stat-growth {
|
|
display: inline-flex; align-items: center; gap: 4px;
|
|
font-size: 12px; font-weight: 600; padding: 2px 8px;
|
|
border-radius: 20px; width: fit-content;
|
|
}
|
|
.stat-growth.up { background: rgba(34,197,94,.15); color: #4ade80; }
|
|
.stat-growth.down { background: rgba(239,68,68,.15); color: #f87171; }
|
|
.stat-growth.flat { background: rgba(156,163,175,.15); color: #9ca3af; }
|
|
.stat-week { font-size: 12px; color: var(--text-secondary); }
|
|
|
|
/* ── Alert banner ── */
|
|
.alert-banner {
|
|
border-radius: 12px; padding: 14px 18px;
|
|
display: flex; align-items: center; gap: 12px;
|
|
margin-bottom: 24px; font-size: 14px;
|
|
}
|
|
.alert-banner.danger { background: rgba(239,68,68,.12); border: 1px solid rgba(239,68,68,.3); color: #f87171; }
|
|
.alert-banner.warn { background: rgba(234,179,8,.12); border: 1px solid rgba(234,179,8,.3); color: #facc15; }
|
|
|
|
/* ── Section card ── */
|
|
.dash-card {
|
|
background: var(--bg-secondary); border: 1px solid var(--border-color);
|
|
border-radius: 14px; padding: 20px; height: 100%;
|
|
}
|
|
.dash-card-title {
|
|
font-size: 15px; font-weight: 600; color: var(--text-primary);
|
|
margin: 0 0 16px; display: flex; align-items: center; gap: 8px;
|
|
}
|
|
.dash-card-title i { color: var(--brand-red); }
|
|
|
|
/* ── Chart container ── */
|
|
.chart-wrap { position: relative; height: 220px; }
|
|
|
|
/* ── Top content table ── */
|
|
.top-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
.top-table th { color: var(--text-secondary); font-weight: 500; padding: 0 10px 10px; text-align: left; border-bottom: 1px solid var(--border-color); }
|
|
.top-table td { padding: 10px; border-bottom: 1px solid rgba(255,255,255,.04); color: var(--text-primary); vertical-align: middle; }
|
|
.top-table tr:last-child td { border-bottom: none; }
|
|
.top-table tr:hover td { background: rgba(255,255,255,.03); }
|
|
.top-thumb { width: 52px; height: 34px; object-fit: cover; border-radius: 5px; background: #333; }
|
|
.top-thumb-placeholder { width: 52px; height: 34px; border-radius: 5px; background: #2a2a2a; display:flex; align-items:center; justify-content:center; color:#555; }
|
|
|
|
/* ── Status progress bars ── */
|
|
.status-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
|
.status-label { width: 90px; font-size: 13px; color: var(--text-secondary); flex-shrink: 0; }
|
|
.status-bar-wrap { flex: 1; height: 8px; background: #2a2a2a; border-radius: 4px; overflow: hidden; }
|
|
.status-bar { height: 100%; border-radius: 4px; transition: width .4s ease; }
|
|
.status-count { width: 36px; text-align: right; font-size: 13px; font-weight: 600; color: var(--text-primary); flex-shrink: 0; }
|
|
|
|
/* ── Engagement metrics ── */
|
|
.engage-item {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,.05);
|
|
}
|
|
.engage-item:last-child { border-bottom: none; padding-bottom: 0; }
|
|
.engage-name { font-size: 13px; color: var(--text-secondary); }
|
|
.engage-val { font-size: 18px; font-weight: 700; color: var(--text-primary); }
|
|
|
|
/* ── Storage breakdown ── */
|
|
.storage-item { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
|
|
.storage-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
|
.storage-name { font-size: 13px; color: var(--text-secondary); flex: 1; }
|
|
.storage-bar-wrap { width: 120px; height: 6px; background: #2a2a2a; border-radius: 3px; overflow: hidden; }
|
|
.storage-bar { height: 100%; border-radius: 3px; }
|
|
.storage-mb { font-size: 13px; font-weight: 600; color: var(--text-primary); width: 60px; text-align: right; }
|
|
|
|
/* ── Recent table ── */
|
|
.recent-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
.recent-table th { color: var(--text-secondary); font-weight: 500; padding: 0 8px 10px; text-align: left; border-bottom: 1px solid var(--border-color); }
|
|
.recent-table td { padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,.04); }
|
|
.recent-table tr:last-child td { border-bottom: none; }
|
|
.user-avatar-sm { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; }
|
|
|
|
/* ── Type doughnut legend ── */
|
|
.type-legend { display: flex; flex-direction: column; gap: 10px; justify-content: center; }
|
|
.type-leg-item { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
|
.type-leg-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
|
|
|
/* ── Country list ── */
|
|
.country-row {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 9px 0; border-bottom: 1px solid rgba(255,255,255,.04);
|
|
}
|
|
.country-row:last-child { border-bottom: none; }
|
|
.country-rank { width: 22px; font-size: 12px; font-weight: 700; color: var(--text-secondary); text-align: right; flex-shrink: 0; }
|
|
.country-flag { font-size: 22px; line-height: 1; flex-shrink: 0; width: 28px; text-align: center; }
|
|
.country-name { flex: 1; font-size: 13px; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.country-bar-wrap { width: 80px; height: 5px; background: #2a2a2a; border-radius: 3px; overflow: hidden; flex-shrink: 0; }
|
|
.country-bar { height: 100%; border-radius: 3px; background: #22d3ee; }
|
|
.country-count { width: 48px; text-align: right; font-size: 13px; font-weight: 600; color: var(--text-primary); flex-shrink: 0; }
|
|
|
|
/* ── Clickable rows ── */
|
|
.clickable-row { cursor: pointer; transition: background .15s; }
|
|
.clickable-row:hover td, .clickable-row:hover { background: rgba(255,255,255,.04) !important; }
|
|
.clickable-card { cursor: pointer; transition: border-color .2s, transform .15s; }
|
|
.clickable-card:hover { border-color: #555 !important; transform: translateY(-2px); }
|
|
.clickable-seg { cursor: pointer; }
|
|
|
|
/* ── Dash Modal ── */
|
|
.dm-overlay {
|
|
position: fixed; inset: 0; z-index: 9000;
|
|
background: rgba(0,0,0,.65); backdrop-filter: blur(4px);
|
|
display: flex; align-items: center; justify-content: center;
|
|
opacity: 0; pointer-events: none; transition: opacity .2s;
|
|
}
|
|
.dm-overlay.open { opacity: 1; pointer-events: auto; }
|
|
.dm-sheet {
|
|
background: var(--bg-secondary); border: 1px solid var(--border-color);
|
|
border-radius: 16px; width: min(760px, 95vw); max-height: 80vh;
|
|
display: flex; flex-direction: column;
|
|
transform: translateY(24px) scale(.97); transition: transform .25s cubic-bezier(.34,1.56,.64,1);
|
|
box-shadow: 0 32px 80px rgba(0,0,0,.6);
|
|
}
|
|
.dm-overlay.open .dm-sheet { transform: translateY(0) scale(1); }
|
|
.dm-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 18px 22px 14px; border-bottom: 1px solid var(--border-color); flex-shrink: 0;
|
|
}
|
|
.dm-title { font-size: 15px; font-weight: 700; color: var(--text-primary); display: flex; align-items: center; gap: 10px; }
|
|
.dm-title i { color: var(--brand-red); font-size: 16px; }
|
|
.dm-close {
|
|
width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--border-color);
|
|
background: transparent; color: var(--text-secondary); cursor: pointer;
|
|
display: flex; align-items: center; justify-content: center; font-size: 16px; transition: all .15s;
|
|
}
|
|
.dm-close:hover { background: var(--border-color); color: var(--text-primary); }
|
|
.dm-body { overflow-y: auto; padding: 18px 22px; flex: 1; }
|
|
.dm-spinner {
|
|
width: 32px; height: 32px; border: 3px solid var(--border-color);
|
|
border-top-color: var(--brand-red); border-radius: 50%;
|
|
animation: dm-spin .7s linear infinite; margin: 0 auto;
|
|
}
|
|
@keyframes dm-spin { to { transform: rotate(360deg); } }
|
|
.dm-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
.dm-table th { color: var(--text-secondary); font-weight: 500; padding: 0 10px 10px; text-align: left; border-bottom: 1px solid var(--border-color); white-space: nowrap; }
|
|
.dm-table td { padding: 10px; border-bottom: 1px solid rgba(255,255,255,.04); color: var(--text-primary); vertical-align: middle; }
|
|
.dm-table tr:last-child td { border-bottom: none; }
|
|
.dm-table tr[onclick]:hover td { background: rgba(255,255,255,.03); }
|
|
.dm-badge { padding: 2px 8px; border-radius: 20px; font-size: 11px; font-weight: 600; }
|
|
|
|
/* ── Quick actions ── */
|
|
.quick-btn {
|
|
display: flex; align-items: center; gap: 10px; padding: 12px 16px;
|
|
background: rgba(255,255,255,.04); border: 1px solid var(--border-color);
|
|
border-radius: 10px; color: var(--text-primary); font-size: 13px; font-weight: 500;
|
|
text-decoration: none; cursor: pointer; transition: background .2s, border-color .2s; width: 100%;
|
|
}
|
|
.quick-btn:hover { background: rgba(255,255,255,.08); border-color: #555; color: var(--text-primary); }
|
|
.quick-btn i { font-size: 18px; }
|
|
</style>
|
|
@endsection
|
|
|
|
@section('content')
|
|
|
|
{{-- ── Alert Banner ── --}}
|
|
@if($failedCount > 0)
|
|
<div class="alert-banner danger">
|
|
<i class="bi bi-exclamation-triangle-fill fs-5"></i>
|
|
<div>
|
|
<strong>{{ $failedCount }} video{{ $failedCount > 1 ? 's' : '' }} failed processing</strong>
|
|
— these uploads need attention.
|
|
<a href="{{ route('admin.videos') }}?status=failed" class="ms-2 text-danger fw-bold">Review now →</a>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
@if($processingCount > 0 || $pendingCount > 0)
|
|
<div class="alert-banner warn">
|
|
<i class="bi bi-hourglass-split fs-5"></i>
|
|
<div>
|
|
<strong>{{ $processingCount + $pendingCount }} video{{ ($processingCount + $pendingCount) > 1 ? 's' : '' }} in queue</strong>
|
|
— {{ $processingCount }} processing, {{ $pendingCount }} pending.
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- ── Stat Cards ── --}}
|
|
<div class="row g-3 mb-4">
|
|
|
|
{{-- Users --}}
|
|
<div class="col-6 col-lg-4 col-xl-2">
|
|
<div class="stat-card clickable-card" onclick="openDashModal('All Users','users',{},'bi-people-fill')" title="Click to view users">
|
|
<div class="stat-card-accent" style="background: linear-gradient(90deg,#6366f1,#818cf8);"></div>
|
|
<div class="stat-card-top">
|
|
<div class="stat-icon" style="background:rgba(99,102,241,.15);color:#818cf8;"><i class="bi bi-people-fill"></i></div>
|
|
</div>
|
|
<div class="stat-value">{{ number_format($stats['totalUsers']) }}</div>
|
|
<div class="stat-label">Total Users</div>
|
|
<div class="stat-growth {{ $stats['growthUsers']['dir'] }}">
|
|
<i class="bi bi-arrow-{{ $stats['growthUsers']['dir'] === 'up' ? 'up' : ($stats['growthUsers']['dir'] === 'down' ? 'down' : 'right') }}-short"></i>
|
|
{{ $stats['growthUsers']['pct'] }}%
|
|
</div>
|
|
<div class="stat-week">+{{ $stats['usersThisWeek'] }} this week</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Videos --}}
|
|
<div class="col-6 col-lg-4 col-xl-2">
|
|
<div class="stat-card clickable-card" onclick="openDashModal('All Videos','videos',{},'bi-play-circle-fill')" title="Click to view videos">
|
|
<div class="stat-card-accent" style="background: linear-gradient(90deg,#e61e1e,#ff6b6b);"></div>
|
|
<div class="stat-card-top">
|
|
<div class="stat-icon" style="background:rgba(230,30,30,.15);color:#ff6b6b;"><i class="bi bi-play-circle-fill"></i></div>
|
|
</div>
|
|
<div class="stat-value">{{ number_format($stats['totalVideos']) }}</div>
|
|
<div class="stat-label">Total Videos</div>
|
|
<div class="stat-growth {{ $stats['growthVideos']['dir'] }}">
|
|
<i class="bi bi-arrow-{{ $stats['growthVideos']['dir'] === 'up' ? 'up' : ($stats['growthVideos']['dir'] === 'down' ? 'down' : 'right') }}-short"></i>
|
|
{{ $stats['growthVideos']['pct'] }}%
|
|
</div>
|
|
<div class="stat-week">+{{ $stats['videosThisWeek'] }} this week</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Views --}}
|
|
<div class="col-6 col-lg-4 col-xl-2">
|
|
<div class="stat-card clickable-card" onclick="openDashModal('Recent Views','views_day',{},'bi-eye-fill')" title="Click to view recent views">
|
|
<div class="stat-card-accent" style="background: linear-gradient(90deg,#06b6d4,#22d3ee);"></div>
|
|
<div class="stat-card-top">
|
|
<div class="stat-icon" style="background:rgba(6,182,212,.15);color:#22d3ee;"><i class="bi bi-eye-fill"></i></div>
|
|
</div>
|
|
<div class="stat-value">{{ number_format($stats['totalViews']) }}</div>
|
|
<div class="stat-label">Total Views</div>
|
|
<div class="stat-growth {{ $stats['growthViews']['dir'] }}">
|
|
<i class="bi bi-arrow-{{ $stats['growthViews']['dir'] === 'up' ? 'up' : ($stats['growthViews']['dir'] === 'down' ? 'down' : 'right') }}-short"></i>
|
|
{{ $stats['growthViews']['pct'] }}%
|
|
</div>
|
|
<div class="stat-week">+{{ number_format($stats['viewsThisWeek']) }} this week</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Likes --}}
|
|
<div class="col-6 col-lg-4 col-xl-2">
|
|
<div class="stat-card clickable-card" onclick="openDashModal('Most Liked Videos','likes',{},'bi-heart-fill')" title="Click to view most liked">
|
|
<div class="stat-card-accent" style="background: linear-gradient(90deg,#ec4899,#f472b6);"></div>
|
|
<div class="stat-card-top">
|
|
<div class="stat-icon" style="background:rgba(236,72,153,.15);color:#f472b6;"><i class="bi bi-heart-fill"></i></div>
|
|
</div>
|
|
<div class="stat-value">{{ number_format($stats['totalLikes']) }}</div>
|
|
<div class="stat-label">Total Likes</div>
|
|
<div class="stat-growth {{ $stats['growthLikes']['dir'] }}">
|
|
<i class="bi bi-arrow-{{ $stats['growthLikes']['dir'] === 'up' ? 'up' : ($stats['growthLikes']['dir'] === 'down' ? 'down' : 'right') }}-short"></i>
|
|
{{ $stats['growthLikes']['pct'] }}%
|
|
</div>
|
|
<div class="stat-week">+{{ $stats['likesThisWeek'] }} this week</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Comments --}}
|
|
<div class="col-6 col-lg-4 col-xl-2">
|
|
<div class="stat-card clickable-card" onclick="openDashModal('Recent Comments','comments',{},'bi-chat-dots-fill')" title="Click to view comments">
|
|
<div class="stat-card-accent" style="background: linear-gradient(90deg,#f59e0b,#fbbf24);"></div>
|
|
<div class="stat-card-top">
|
|
<div class="stat-icon" style="background:rgba(245,158,11,.15);color:#fbbf24;"><i class="bi bi-chat-dots-fill"></i></div>
|
|
</div>
|
|
<div class="stat-value">{{ number_format($stats['totalComments']) }}</div>
|
|
<div class="stat-label">Total Comments</div>
|
|
<div class="stat-growth flat"><i class="bi bi-arrow-right-short"></i> —</div>
|
|
<div class="stat-week">+{{ $stats['commentsThisWeek'] }} this week</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Ready Videos --}}
|
|
<div class="col-6 col-lg-4 col-xl-2">
|
|
<div class="stat-card clickable-card" onclick="openDashModal('Live Videos','videos',{status:'ready'},'bi-check-circle-fill')" title="Click to view live videos">
|
|
<div class="stat-card-accent" style="background: linear-gradient(90deg,#22c55e,#4ade80);"></div>
|
|
<div class="stat-card-top">
|
|
<div class="stat-icon" style="background:rgba(34,197,94,.15);color:#4ade80;"><i class="bi bi-check-circle-fill"></i></div>
|
|
</div>
|
|
<div class="stat-value">{{ number_format($videosByStatus->get('ready', 0)) }}</div>
|
|
<div class="stat-label">Live Videos</div>
|
|
@php $failPct = $stats['totalVideos'] > 0 ? round(($failedCount/$stats['totalVideos'])*100,1) : 0; @endphp
|
|
<div class="stat-growth {{ $failPct > 5 ? 'down' : 'flat' }}">
|
|
<i class="bi bi-x-circle"></i> {{ $failedCount }} failed
|
|
</div>
|
|
<div class="stat-week">{{ $processingCount + $pendingCount }} in queue</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{{-- ── Activity Chart ── --}}
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-lg-8">
|
|
<div class="dash-card">
|
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
|
<h6 class="dash-card-title mb-0"><i class="bi bi-graph-up"></i> Activity — Last 30 Days</h6>
|
|
<div class="d-flex gap-2" id="chartToggle">
|
|
<button class="btn btn-sm btn-outline-secondary active" data-ds="views">Views</button>
|
|
<button class="btn btn-sm btn-outline-secondary" data-ds="users">Users</button>
|
|
<button class="btn btn-sm btn-outline-secondary" data-ds="videos">Uploads</button>
|
|
</div>
|
|
</div>
|
|
<div class="chart-wrap">
|
|
<canvas id="activityChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-4">
|
|
<div class="dash-card">
|
|
<h6 class="dash-card-title"><i class="bi bi-pie-chart-fill"></i> Content by Type</h6>
|
|
<div class="d-flex align-items-center gap-4" style="height: 180px;">
|
|
<div style="width: 140px; height: 140px; flex-shrink:0;">
|
|
<canvas id="typeChart"></canvas>
|
|
</div>
|
|
<div class="type-legend">
|
|
<div class="type-leg-item"><span class="type-leg-dot" style="background:#e61e1e;"></span> Generic <strong>{{ $videosByType->get('generic', 0) }}</strong></div>
|
|
<div class="type-leg-item"><span class="type-leg-dot" style="background:#f59e0b;"></span> Music <strong>{{ $videosByType->get('music', 0) }}</strong></div>
|
|
<div class="type-leg-item"><span class="type-leg-dot" style="background:#6366f1;"></span> Match <strong>{{ $videosByType->get('match', 0) }}</strong></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ── Top Content & Uploaders ── --}}
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-lg-7">
|
|
<div class="dash-card">
|
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
|
<h6 class="dash-card-title mb-0"><i class="bi bi-trophy-fill"></i> Top Videos by Views</h6>
|
|
<a href="{{ route('admin.videos') }}" class="btn btn-outline-secondary btn-sm">View All</a>
|
|
</div>
|
|
<table class="top-table">
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th colspan="2">Video</th>
|
|
<th>Views</th>
|
|
<th>Likes</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@forelse($topVideos as $i => $v)
|
|
<tr class="clickable-row" onclick="window.open('{{ route('videos.show', \App\Models\Video::encodeId($v->id)) }}','_blank')">
|
|
<td style="color:var(--text-secondary);font-weight:700;">{{ $i + 1 }}</td>
|
|
<td>
|
|
@if($v->thumbnail)
|
|
<img src="{{ asset('storage/thumbnails/'.$v->thumbnail) }}" class="top-thumb" alt="">
|
|
@else
|
|
<div class="top-thumb-placeholder"><i class="bi bi-play-fill"></i></div>
|
|
@endif
|
|
</td>
|
|
<td>
|
|
<a href="{{ route('videos.show', \App\Models\Video::encodeId($v->id)) }}" target="_blank" class="text-decoration-none text-white fw-500" style="font-size:13px;display:block;max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ $v->title }}</a>
|
|
<small style="color:var(--text-secondary);">{{ $v->username }}</small>
|
|
</td>
|
|
<td><span style="color:#22d3ee;font-weight:600;">{{ number_format($v->view_count) }}</span></td>
|
|
<td><span style="color:#f472b6;font-weight:600;">{{ number_format($v->like_count) }}</span></td>
|
|
</tr>
|
|
@empty
|
|
<tr><td colspan="5" class="text-center" style="color:var(--text-secondary);padding:20px;">No videos yet</td></tr>
|
|
@endforelse
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-5">
|
|
<div class="dash-card">
|
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
|
<h6 class="dash-card-title mb-0"><i class="bi bi-person-fill"></i> Top Uploaders</h6>
|
|
<a href="{{ route('admin.users') }}" class="btn btn-outline-secondary btn-sm">View All</a>
|
|
</div>
|
|
<table class="top-table">
|
|
<thead>
|
|
<tr><th>#</th><th>User</th><th>Videos</th><th>Views</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
@forelse($topUploaders as $i => $u)
|
|
<tr class="clickable-row" onclick="openDashModal('Videos by {{ addslashes($u->name) }}','videos',{uploader_id:{{ $u->id }}},'bi-person-fill')">
|
|
<td style="color:var(--text-secondary);font-weight:700;">{{ $i + 1 }}</td>
|
|
<td>
|
|
<div style="font-size:13px;font-weight:500;max-width:130px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ $u->name }}</div>
|
|
</td>
|
|
<td><span style="color:#818cf8;font-weight:600;">{{ $u->video_count }}</span></td>
|
|
<td><span style="color:#22d3ee;font-weight:600;">{{ number_format($u->total_views ?? 0) }}</span></td>
|
|
</tr>
|
|
@empty
|
|
<tr><td colspan="4" class="text-center" style="color:var(--text-secondary);padding:20px;">No users yet</td></tr>
|
|
@endforelse
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ── Status / Visibility / Engagement ── --}}
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-md-4">
|
|
<div class="dash-card">
|
|
<h6 class="dash-card-title"><i class="bi bi-sliders"></i> Processing Status</h6>
|
|
@php $totalV = max($stats['totalVideos'], 1); @endphp
|
|
@foreach([['ready','#22c55e'],['processing','#6366f1'],['pending','#f59e0b'],['failed','#ef4444']] as [$s,$color])
|
|
@php $cnt = $videosByStatus->get($s, 0); @endphp
|
|
<div class="status-row clickable-seg" style="cursor:pointer" onclick="openDashModal('{{ ucfirst($s) }} Videos','videos',{status:'{{ $s }}'},'bi-sliders')" title="{{ $cnt }} {{ $s }} videos">
|
|
<div class="status-label">{{ ucfirst($s) }}</div>
|
|
<div class="status-bar-wrap">
|
|
<div class="status-bar" style="width:{{ round(($cnt/$totalV)*100,1) }}%;background:{{ $color }};"></div>
|
|
</div>
|
|
<div class="status-count">{{ $cnt }}</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="dash-card">
|
|
<h6 class="dash-card-title"><i class="bi bi-shield-lock-fill"></i> Visibility</h6>
|
|
@foreach([['public','#22c55e'],['unlisted','#f59e0b'],['private','#6b7280']] as [$v,$color])
|
|
@php $cnt = $videosByVisibility->get($v, 0); @endphp
|
|
<div class="status-row clickable-seg" style="cursor:pointer" onclick="openDashModal('{{ ucfirst($v) }} Videos','videos',{visibility:'{{ $v }}'},'bi-shield-lock-fill')" title="{{ $cnt }} {{ $v }} videos">
|
|
<div class="status-label">{{ ucfirst($v) }}</div>
|
|
<div class="status-bar-wrap">
|
|
<div class="status-bar" style="width:{{ round(($cnt/$totalV)*100,1) }}%;background:{{ $color }};"></div>
|
|
</div>
|
|
<div class="status-count">{{ $cnt }}</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="dash-card">
|
|
<h6 class="dash-card-title"><i class="bi bi-bar-chart-fill"></i> Engagement Metrics</h6>
|
|
<div class="engage-item">
|
|
<div class="engage-name">Avg views / video</div>
|
|
<div class="engage-val">{{ number_format($avgViewsPerVideo, 1) }}</div>
|
|
</div>
|
|
<div class="engage-item">
|
|
<div class="engage-name">Like-to-view ratio</div>
|
|
<div class="engage-val">{{ $likeToViewRatio }}%</div>
|
|
</div>
|
|
<div class="engage-item">
|
|
<div class="engage-name">Avg uploads / user</div>
|
|
<div class="engage-val">{{ number_format($avgVideosPerUser, 1) }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ── Viewers by Country ── --}}
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-lg-5">
|
|
<div class="dash-card" style="height:100%;">
|
|
<h6 class="dash-card-title"><i class="bi bi-globe2"></i> Viewers by Country</h6>
|
|
@if($viewsByCountry->isEmpty())
|
|
<div style="text-align:center;padding:40px 0;color:var(--text-secondary);font-size:13px;">
|
|
<i class="bi bi-globe" style="font-size:32px;display:block;margin-bottom:8px;"></i>
|
|
No geo data yet — new views will be tracked automatically.
|
|
</div>
|
|
@else
|
|
@php $maxViews = $viewsByCountry->first()->total; @endphp
|
|
@foreach($viewsByCountry as $i => $row)
|
|
<div class="country-row clickable-seg" style="cursor:pointer" onclick="openDashModal('Viewers from {{ addslashes($row->country_name ?? $row->country ?? 'Unknown') }}','country_viewers',{country:'{{ $row->country }}'},'bi-globe2')">
|
|
<div class="country-rank">{{ $i + 1 }}</div>
|
|
<div class="country-flag" title="{{ $row->country }}">{{ $row->country ? countryCodeToFlag($row->country) : '🌍' }}</div>
|
|
<div class="country-name">{{ $row->country_name ?? 'Unknown' }}</div>
|
|
<div class="country-bar-wrap">
|
|
<div class="country-bar" style="width:{{ round(($row->total / $maxViews) * 100) }}%;"></div>
|
|
</div>
|
|
<div class="country-count">{{ number_format($row->total) }}</div>
|
|
</div>
|
|
@endforeach
|
|
@endif
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-7">
|
|
<div class="dash-card" style="height:100%; display:flex; flex-direction:column;">
|
|
<h6 class="dash-card-title" style="flex-shrink:0;"><i class="bi bi-bar-chart-horizontal-fill"></i> Top Countries — View Distribution</h6>
|
|
@if($viewsByCountry->isEmpty())
|
|
<div style="text-align:center;padding:60px 0;color:var(--text-secondary);font-size:13px;">Chart will appear once geo data is collected.</div>
|
|
@else
|
|
<div style="flex:1; min-height:200px; position:relative;">
|
|
<canvas id="countryChart"></canvas>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ── Recent Users & Videos ── --}}
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-lg-6">
|
|
<div class="dash-card">
|
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
|
<h6 class="dash-card-title mb-0"><i class="bi bi-person-plus-fill"></i> Recent Users</h6>
|
|
<a href="{{ route('admin.users') }}" class="btn btn-outline-secondary btn-sm">View All</a>
|
|
</div>
|
|
<table class="recent-table">
|
|
<thead><tr><th>User</th><th>Role</th><th>Joined</th></tr></thead>
|
|
<tbody>
|
|
@forelse($recentUsers as $user)
|
|
<tr class="clickable-row" onclick="openDashModal('Videos by {{ addslashes($user->name) }}','videos',{uploader_id:{{ $user->id }}},'bi-person-fill')">
|
|
<td>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<img src="{{ $user->avatar_url }}" class="user-avatar-sm" alt="">
|
|
<div>
|
|
<div style="font-weight:500;">{{ $user->name }}</div>
|
|
<small style="color:var(--text-secondary);">{{ Str::limit($user->email, 22) }}</small>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
@if($user->role === 'super_admin')
|
|
<span class="badge-role badge-super-admin">Super Admin</span>
|
|
@elseif($user->role === 'admin')
|
|
<span class="badge-role badge-admin">Admin</span>
|
|
@else
|
|
<span class="badge-role badge-user">User</span>
|
|
@endif
|
|
</td>
|
|
<td style="color:var(--text-secondary);font-size:12px;">{{ $user->created_at->diffForHumans() }}</td>
|
|
</tr>
|
|
@empty
|
|
<tr><td colspan="3" class="text-center" style="color:var(--text-secondary);padding:20px;">No users</td></tr>
|
|
@endforelse
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-6">
|
|
<div class="dash-card">
|
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
|
<h6 class="dash-card-title mb-0"><i class="bi bi-cloud-upload-fill"></i> Recent Uploads</h6>
|
|
<a href="{{ route('admin.videos') }}" class="btn btn-outline-secondary btn-sm">View All</a>
|
|
</div>
|
|
<table class="recent-table">
|
|
<thead><tr><th colspan="2">Video</th><th>Status</th><th>When</th></tr></thead>
|
|
<tbody>
|
|
@forelse($recentVideos as $video)
|
|
<tr class="clickable-row" onclick="window.open('{{ route('videos.show', $video) }}','_blank')">
|
|
<td>
|
|
@if($video->thumbnail)
|
|
<img src="{{ asset('storage/thumbnails/'.$video->thumbnail) }}" class="top-thumb" alt="">
|
|
@else
|
|
<div class="top-thumb-placeholder"><i class="bi bi-play-fill"></i></div>
|
|
@endif
|
|
</td>
|
|
<td>
|
|
<div style="max-width:180px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:13px;font-weight:500;">{{ $video->title }}</div>
|
|
<small style="color:var(--text-secondary);">{{ $video->user->name }}</small>
|
|
</td>
|
|
<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
|
|
</td>
|
|
<td style="color:var(--text-secondary);font-size:12px;">{{ $video->created_at->diffForHumans() }}</td>
|
|
</tr>
|
|
@empty
|
|
<tr><td colspan="4" class="text-center" style="color:var(--text-secondary);padding:20px;">No videos</td></tr>
|
|
@endforelse
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ── Storage & Quick Actions ── --}}
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-lg-6">
|
|
<div class="dash-card">
|
|
<h6 class="dash-card-title"><i class="bi bi-hdd-stack-fill"></i> Storage Breakdown</h6>
|
|
@php
|
|
$storageItems = [
|
|
['Videos', $storage['videos'], '#e61e1e'],
|
|
['Thumbnails', $storage['thumbnails'], '#f59e0b'],
|
|
['Avatars', $storage['avatars'], '#6366f1'],
|
|
['Images', $storage['images'], '#22c55e'],
|
|
];
|
|
$totalSt = max($storage['total'], 0.01);
|
|
@endphp
|
|
@foreach($storageItems as [$name, $mb, $color])
|
|
<div class="storage-item">
|
|
<div class="storage-dot" style="background:{{ $color }};"></div>
|
|
<div class="storage-name">{{ $name }}</div>
|
|
<div class="storage-bar-wrap">
|
|
<div class="storage-bar" style="width:{{ round(($mb/$totalSt)*100,1) }}%;background:{{ $color }};"></div>
|
|
</div>
|
|
<div class="storage-mb">{{ $mb }} MB</div>
|
|
</div>
|
|
@endforeach
|
|
<div class="d-flex align-items-center justify-content-between mt-3 pt-3" style="border-top:1px solid var(--border-color);">
|
|
<span style="color:var(--text-secondary);font-size:13px;">Total used</span>
|
|
<span style="font-weight:700;font-size:16px;">{{ $storage['total'] }} MB</span>
|
|
</div>
|
|
<button id="cleanupBtn" class="btn btn-danger w-100 mt-3">
|
|
<i class="bi bi-trash3-fill me-2"></i> Clean Orphaned Files
|
|
</button>
|
|
<div id="cleanupStatus" class="mt-2"></div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-6">
|
|
<div class="dash-card">
|
|
<h6 class="dash-card-title"><i class="bi bi-lightning-fill"></i> Quick Actions</h6>
|
|
<div class="d-flex flex-column gap-2">
|
|
<a href="{{ route('admin.users') }}" class="quick-btn">
|
|
<i class="bi bi-people" style="color:#818cf8;"></i> Manage Users
|
|
</a>
|
|
<a href="{{ route('admin.videos') }}" class="quick-btn">
|
|
<i class="bi bi-play-circle" style="color:#ff6b6b;"></i> Manage Videos
|
|
</a>
|
|
@if($failedCount > 0)
|
|
<a href="{{ route('admin.videos') }}?status=failed" class="quick-btn" style="border-color:rgba(239,68,68,.4);">
|
|
<i class="bi bi-exclamation-triangle" style="color:#f87171;"></i>
|
|
Review {{ $failedCount }} Failed Video{{ $failedCount > 1 ? 's' : '' }}
|
|
</a>
|
|
@endif
|
|
<a href="{{ route('admin.logs') }}" class="quick-btn">
|
|
<i class="bi bi-terminal" style="color:#22d3ee;"></i> View System Logs
|
|
</a>
|
|
<a href="{{ route('videos.index') }}" target="_blank" class="quick-btn">
|
|
<i class="bi bi-box-arrow-up-right" style="color:#4ade80;"></i> Open Platform
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ── Dash Modal ── --}}
|
|
<div class="dm-overlay" id="dashModal" onclick="if(event.target===this)closeDashModal()">
|
|
<div class="dm-sheet">
|
|
<div class="dm-header">
|
|
<div class="dm-title">
|
|
<i class="bi bi-circle-fill" id="dashModalIcon" style="font-size:10px;"></i>
|
|
<span id="dashModalTitle">Loading…</span>
|
|
</div>
|
|
<button class="dm-close" onclick="closeDashModal()"><i class="bi bi-x-lg"></i></button>
|
|
</div>
|
|
<div class="dm-body" id="dashModalBody">
|
|
<div style="text-align:center;padding:48px;">
|
|
<div class="dm-spinner"></div>
|
|
<p style="color:var(--text-secondary);margin-top:14px;font-size:13px;">Loading…</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@endsection
|
|
|
|
@php
|
|
function countryCodeToFlag(string $code): string {
|
|
$code = strtoupper(trim($code));
|
|
if (strlen($code) !== 2) return '🌍';
|
|
$chars = '';
|
|
foreach (str_split($code) as $c) {
|
|
$chars .= mb_chr(0x1F1E6 + ord($c) - ord('A'));
|
|
}
|
|
return $chars;
|
|
}
|
|
@endphp
|
|
|
|
@section('scripts')
|
|
<script>
|
|
// ── Activity Line Chart ─────────────────────────────────────────
|
|
const labels = {!! $chartLabels !!};
|
|
const usersData = {!! $chartUsersData !!};
|
|
const videosData = {!! $chartVideosData !!};
|
|
const viewsData = {!! $chartViewsData !!};
|
|
|
|
const datasets = {
|
|
views: { label: 'Views', data: viewsData, borderColor: '#22d3ee', backgroundColor: 'rgba(34,211,238,.08)', tension: .4, fill: true, pointRadius: 2, pointHoverRadius: 5 },
|
|
users: { label: 'Users', data: usersData, borderColor: '#818cf8', backgroundColor: 'rgba(129,140,248,.08)', tension: .4, fill: true, pointRadius: 2, pointHoverRadius: 5 },
|
|
videos: { label: 'Uploads', data: videosData, borderColor: '#ff6b6b', backgroundColor: 'rgba(255,107,107,.08)', tension: .4, fill: true, pointRadius: 2, pointHoverRadius: 5 },
|
|
};
|
|
|
|
const activityChart = new Chart(document.getElementById('activityChart'), {
|
|
type: 'line',
|
|
data: { labels, datasets: [datasets.views] },
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false,
|
|
plugins: { legend: { display: false }, tooltip: { mode: 'index', intersect: false, backgroundColor: '#1e1e1e', borderColor: '#303030', borderWidth: 1, titleColor: '#f1f1f1', bodyColor: '#aaa' } },
|
|
scales: {
|
|
x: { grid: { color: 'rgba(255,255,255,.05)' }, ticks: { color: '#666', maxTicksLimit: 8, font: { size: 11 } } },
|
|
y: { grid: { color: 'rgba(255,255,255,.05)' }, ticks: { color: '#666', font: { size: 11 } }, beginAtZero: true },
|
|
},
|
|
interaction: { mode: 'nearest', axis: 'x', intersect: false },
|
|
}
|
|
});
|
|
|
|
// Toggle buttons
|
|
document.getElementById('chartToggle').addEventListener('click', e => {
|
|
const btn = e.target.closest('button[data-ds]');
|
|
if (!btn) return;
|
|
document.querySelectorAll('#chartToggle button').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
activityChart.data.datasets = [datasets[btn.dataset.ds]];
|
|
activityChart.update();
|
|
});
|
|
|
|
// ── Type Doughnut ───────────────────────────────────────────────
|
|
new Chart(document.getElementById('typeChart'), {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: ['Generic','Music','Match'],
|
|
datasets: [{
|
|
data: [{{ $videosByType->get('generic',0) }}, {{ $videosByType->get('music',0) }}, {{ $videosByType->get('match',0) }}],
|
|
backgroundColor: ['#e61e1e','#f59e0b','#6366f1'],
|
|
borderColor: '#1e1e1e', borderWidth: 3,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false,
|
|
plugins: { legend: { display: false }, tooltip: { backgroundColor: '#1e1e1e', borderColor: '#303030', borderWidth: 1, titleColor: '#f1f1f1', bodyColor: '#aaa' } },
|
|
cutout: '68%',
|
|
}
|
|
});
|
|
|
|
// ── Country Chart ───────────────────────────────────────────────
|
|
@if($viewsByCountry->isNotEmpty())
|
|
(function() {
|
|
const countryData = @json($viewsByCountry);
|
|
const labels = countryData.map(r => r.country_name || r.country || '');
|
|
const values = countryData.map(r => r.total);
|
|
const maxVal = Math.max(...values);
|
|
const colors = values.map(v => `rgba(34,211,238,${(0.3 + 0.7 * v / maxVal).toFixed(2)})`);
|
|
|
|
const canvas = document.getElementById('countryChart');
|
|
const container = canvas.parentElement; // position:relative — flags overlay here
|
|
|
|
// Overlay real <img> flags — canvas ctx cannot render emoji flag sequences reliably.
|
|
// yAxis.right = left edge of bar area; flags sit in the ticks.padding gap before the bars.
|
|
function placeFlagOverlays() {
|
|
container.querySelectorAll('.cty-flag').forEach(e => e.remove());
|
|
const yAxis = chart.scales.y;
|
|
if (!yAxis) return;
|
|
const fW = 22, fH = 15;
|
|
// Place flag just to the left of the bar area (inside the ticks.padding gap)
|
|
const x = Math.round(yAxis.right - fW - 4);
|
|
countryData.forEach((row, i) => {
|
|
if (!row.country || row.country.length !== 2) return;
|
|
const yPx = Math.round(yAxis.getPixelForTick(i));
|
|
const img = document.createElement('img');
|
|
img.src = `https://flagcdn.com/w40/${row.country.toLowerCase()}.png`;
|
|
img.className = 'cty-flag';
|
|
img.style.cssText = [
|
|
'position:absolute',
|
|
`width:${fW}px`,
|
|
`height:${fH}px`,
|
|
`top:${yPx - Math.round(fH / 2)}px`,
|
|
`left:${x}px`,
|
|
'pointer-events:none',
|
|
'border-radius:3px',
|
|
'object-fit:cover',
|
|
'box-shadow:0 1px 3px rgba(0,0,0,.4)',
|
|
].join(';');
|
|
container.appendChild(img);
|
|
});
|
|
}
|
|
|
|
const chart = new Chart(canvas, {
|
|
type: 'bar',
|
|
data: {
|
|
labels,
|
|
datasets: [{
|
|
data: values,
|
|
backgroundColor: colors,
|
|
borderColor: 'rgba(34,211,238,0.6)',
|
|
borderWidth: 1,
|
|
borderRadius: 4,
|
|
}]
|
|
},
|
|
options: {
|
|
indexAxis: 'y',
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: { onComplete: placeFlagOverlays },
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: {
|
|
backgroundColor: '#1e1e1e', borderColor: '#303030', borderWidth: 1,
|
|
titleColor: '#f1f1f1', bodyColor: '#aaa',
|
|
callbacks: { label: ctx => ' ' + ctx.parsed.x.toLocaleString() + ' views' }
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
grid: { color: 'rgba(255,255,255,.05)' },
|
|
ticks: { color: '#666', font: { size: 11 } },
|
|
beginAtZero: true,
|
|
},
|
|
y: {
|
|
grid: { display: false },
|
|
// padding must be >= flagWidth + gaps so labels don't overlap flags
|
|
ticks: { color: '#ccc', font: { size: 12 }, padding: 32 },
|
|
}
|
|
},
|
|
}
|
|
});
|
|
|
|
// Reposition overlays whenever the canvas resizes (responsive reflow)
|
|
new ResizeObserver(() => requestAnimationFrame(placeFlagOverlays)).observe(canvas);
|
|
})();
|
|
@endif
|
|
|
|
// ── Dashboard Modal ─────────────────────────────────────────────
|
|
const MODAL_URL = '{{ route("admin.dashboard.modal") }}';
|
|
const CSRF = document.querySelector('meta[name="csrf-token"]')?.content ?? '';
|
|
|
|
function esc(str) {
|
|
if (str == null) return '';
|
|
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
}
|
|
|
|
function statusBadge(s) {
|
|
const map = { ready: ['#22c55e','Ready'], processing: ['#6366f1','Processing'], pending: ['#f59e0b','Pending'], failed: ['#ef4444','Failed'] };
|
|
const [color, label] = map[s] || ['#6b7280', s];
|
|
return `<span class="dm-badge" style="background:${color}22;color:${color};">${label}</span>`;
|
|
}
|
|
function roleBadge(r) {
|
|
const map = { super_admin: ['#f59e0b','Super Admin'], admin: ['#818cf8','Admin'], user: ['#6b7280','User'] };
|
|
const [color, label] = map[r] || ['#6b7280', r];
|
|
return `<span class="dm-badge" style="background:${color}22;color:${color};">${label}</span>`;
|
|
}
|
|
function thumb(url, title) {
|
|
if (url) return `<img src="${esc(url)}" alt="" style="width:52px;height:34px;object-fit:cover;border-radius:5px;">`;
|
|
return `<div style="width:52px;height:34px;border-radius:5px;background:#2a2a2a;display:flex;align-items:center;justify-content:center;color:#555;font-size:14px;"><i class="bi bi-play-fill"></i></div>`;
|
|
}
|
|
|
|
function renderUsersTable(items) {
|
|
if (!items.length) return '<p style="color:var(--text-secondary);text-align:center;padding:32px;">No users found.</p>';
|
|
const rows = items.map(u => `
|
|
<tr style="cursor:pointer;" onclick="window.open('${esc(u.url)}','_blank')">
|
|
<td><div style="display:flex;align-items:center;gap:10px;">
|
|
<img src="${esc(u.avatar)}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;" alt="">
|
|
<div>
|
|
<div style="font-weight:500;font-size:13px;">${esc(u.name)}</div>
|
|
<div style="font-size:11px;color:var(--text-secondary);">${esc(u.email)}</div>
|
|
</div>
|
|
</div></td>
|
|
<td>${roleBadge(u.role)}</td>
|
|
<td style="color:var(--text-secondary);font-size:12px;white-space:nowrap;">${esc(u.joined)}</td>
|
|
</tr>`).join('');
|
|
return `<table class="dm-table"><thead><tr><th>User</th><th>Role</th><th>Joined</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
}
|
|
|
|
function renderVideosTable(items) {
|
|
if (!items.length) return '<p style="color:var(--text-secondary);text-align:center;padding:32px;">No videos found.</p>';
|
|
const rows = items.map(v => `
|
|
<tr style="cursor:pointer;" onclick="window.open('${esc(v.url)}','_blank')">
|
|
<td>${thumb(v.thumbnail, v.title)}</td>
|
|
<td><div style="max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500;font-size:13px;">${esc(v.title)}</div>
|
|
<div style="font-size:11px;color:var(--text-secondary);">${esc(v.owner)}</div></td>
|
|
<td>${statusBadge(v.status)}</td>
|
|
<td style="color:#22d3ee;font-weight:600;white-space:nowrap;">${Number(v.views).toLocaleString()}</td>
|
|
<td style="color:var(--text-secondary);font-size:12px;white-space:nowrap;">${esc(v.uploaded)}</td>
|
|
</tr>`).join('');
|
|
return `<table class="dm-table"><thead><tr><th></th><th>Video</th><th>Status</th><th>Views</th><th>Uploaded</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
}
|
|
|
|
function renderTopVideosTable(items) {
|
|
if (!items.length) return '<p style="color:var(--text-secondary);text-align:center;padding:32px;">No videos found.</p>';
|
|
const rows = items.map((v, i) => `
|
|
<tr style="cursor:pointer;" onclick="window.open('${esc(v.url)}','_blank')">
|
|
<td style="color:var(--text-secondary);font-weight:700;width:28px;">${i+1}</td>
|
|
<td>${thumb(v.thumbnail, v.title)}</td>
|
|
<td><div style="max-width:180px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500;font-size:13px;">${esc(v.title)}</div>
|
|
<div style="font-size:11px;color:var(--text-secondary);">${esc(v.owner)}</div></td>
|
|
<td style="color:#22d3ee;font-weight:600;white-space:nowrap;">${Number(v.views).toLocaleString()}</td>
|
|
<td style="color:#f472b6;font-weight:600;white-space:nowrap;">${Number(v.likes).toLocaleString()}</td>
|
|
</tr>`).join('');
|
|
return `<table class="dm-table"><thead><tr><th>#</th><th></th><th>Video</th><th>Views</th><th>Likes</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
}
|
|
|
|
function renderViewsTable(items) {
|
|
if (!items.length) return '<p style="color:var(--text-secondary);text-align:center;padding:32px;">No views found.</p>';
|
|
const rows = items.map(v => `
|
|
<tr style="cursor:pointer;" onclick="window.open('${esc(v.url)}','_blank')">
|
|
<td style="max-width:180px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:13px;font-weight:500;">${esc(v.video)}</td>
|
|
<td style="font-size:13px;color:var(--text-secondary);">${esc(v.viewer)}</td>
|
|
<td style="font-size:13px;">${esc(v.country)}</td>
|
|
<td style="font-size:12px;color:var(--text-secondary);white-space:nowrap;">${esc(v.time)}</td>
|
|
</tr>`).join('');
|
|
return `<table class="dm-table"><thead><tr><th>Video</th><th>Viewer</th><th>Country</th><th>When</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
}
|
|
|
|
function renderCommentsTable(items) {
|
|
if (!items.length) return '<p style="color:var(--text-secondary);text-align:center;padding:32px;">No comments found.</p>';
|
|
const rows = items.map(c => `
|
|
<tr style="cursor:pointer;" onclick="window.open('${esc(c.url)}','_blank')">
|
|
<td style="font-size:13px;font-weight:500;white-space:nowrap;">${esc(c.user)}</td>
|
|
<td style="font-size:12px;color:var(--text-secondary);max-width:240px;">${esc(c.body)}</td>
|
|
<td style="font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:140px;">${esc(c.video)}</td>
|
|
<td style="font-size:12px;color:var(--text-secondary);white-space:nowrap;">${esc(c.time)}</td>
|
|
</tr>`).join('');
|
|
return `<table class="dm-table"><thead><tr><th>User</th><th>Comment</th><th>Video</th><th>When</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
}
|
|
|
|
function renderUploadersTable(items) {
|
|
if (!items.length) return '<p style="color:var(--text-secondary);text-align:center;padding:32px;">No uploaders found.</p>';
|
|
const rows = items.map((u, i) => `
|
|
<tr style="cursor:pointer;" onclick="window.open('${esc(u.url)}','_blank')">
|
|
<td style="color:var(--text-secondary);font-weight:700;width:28px;">${i+1}</td>
|
|
<td><div style="display:flex;align-items:center;gap:8px;">
|
|
<img src="${esc(u.avatar)}" style="width:28px;height:28px;border-radius:50%;object-fit:cover;" alt="">
|
|
<span style="font-size:13px;font-weight:500;">${esc(u.name)}</span>
|
|
</div></td>
|
|
<td style="color:#818cf8;font-weight:600;">${Number(u.videos).toLocaleString()}</td>
|
|
<td style="color:#22d3ee;font-weight:600;">${Number(u.views).toLocaleString()}</td>
|
|
</tr>`).join('');
|
|
return `<table class="dm-table"><thead><tr><th>#</th><th>User</th><th>Videos</th><th>Views</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
}
|
|
|
|
function renderDashModal(data) {
|
|
const body = document.getElementById('dashModalBody');
|
|
let html = '';
|
|
switch (data.type) {
|
|
case 'users': html = renderUsersTable(data.items); break;
|
|
case 'videos': html = renderVideosTable(data.items); break;
|
|
case 'top_videos': html = renderTopVideosTable(data.items); break;
|
|
case 'views': html = renderViewsTable(data.items); break;
|
|
case 'comments': html = renderCommentsTable(data.items); break;
|
|
case 'uploaders': html = renderUploadersTable(data.items); break;
|
|
default: html = '<p style="color:var(--text-secondary);padding:24px;">Unknown response type.</p>';
|
|
}
|
|
body.innerHTML = html;
|
|
}
|
|
|
|
function openDashModal(title, resource, params, icon) {
|
|
const modal = document.getElementById('dashModal');
|
|
const titleEl = document.getElementById('dashModalTitle');
|
|
const iconEl = document.getElementById('dashModalIcon');
|
|
const body = document.getElementById('dashModalBody');
|
|
|
|
titleEl.textContent = title;
|
|
iconEl.className = 'bi ' + (icon || 'bi-circle-fill');
|
|
body.innerHTML = '<div style="text-align:center;padding:48px;"><div class="dm-spinner"></div><p style="color:var(--text-secondary);margin-top:14px;font-size:13px;">Loading…</p></div>';
|
|
modal.classList.add('open');
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
const qs = new URLSearchParams({ resource, ...params });
|
|
fetch(MODAL_URL + '?' + qs.toString(), {
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': CSRF, 'X-Requested-With': 'XMLHttpRequest' },
|
|
credentials: 'same-origin',
|
|
})
|
|
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
|
.then(data => renderDashModal(data))
|
|
.catch(err => {
|
|
body.innerHTML = `<p style="color:#f87171;text-align:center;padding:32px;"><i class="bi bi-exclamation-triangle-fill me-2"></i>Failed to load: ${esc(err.message)}</p>`;
|
|
});
|
|
}
|
|
|
|
function closeDashModal() {
|
|
document.getElementById('dashModal').classList.remove('open');
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDashModal(); });
|
|
|
|
// ── Chart click handlers ─────────────────────────────────────────
|
|
const chartDatesRaw = {!! $chartDatesRaw ?? '[]' !!};
|
|
|
|
activityChart.options.onClick = function(evt, elements) {
|
|
if (!elements.length) return;
|
|
const idx = elements[0].index;
|
|
const date = chartDatesRaw[idx];
|
|
if (!date) return;
|
|
const activeDs = activityChart.data.datasets[0];
|
|
const ds = document.querySelector('#chartToggle button.active')?.dataset.ds ?? 'views';
|
|
if (ds === 'users') {
|
|
openDashModal('Users joined on ' + date, 'users', { date }, 'bi-people-fill');
|
|
} else if (ds === 'videos') {
|
|
openDashModal('Uploads on ' + date, 'videos', { date }, 'bi-play-circle-fill');
|
|
} else {
|
|
openDashModal('Views on ' + date, 'views_day', { date }, 'bi-eye-fill');
|
|
}
|
|
};
|
|
activityChart.update();
|
|
|
|
// Type doughnut click
|
|
const typeChartInst = Chart.getChart('typeChart');
|
|
if (typeChartInst) {
|
|
typeChartInst.options.onClick = function(evt, elements) {
|
|
if (!elements.length) return;
|
|
const types = ['generic', 'music', 'match'];
|
|
const labels = ['Generic Videos', 'Music Videos', 'Match Videos'];
|
|
const icons = ['bi-play-circle-fill', 'bi-music-note-beamed', 'bi-shield-fill'];
|
|
const idx = elements[0].index;
|
|
openDashModal(labels[idx], 'videos', { type: types[idx] }, icons[idx]);
|
|
};
|
|
typeChartInst.update();
|
|
}
|
|
|
|
// Country bar chart click
|
|
const countryChartInst = Chart.getChart('countryChart');
|
|
if (countryChartInst) {
|
|
const countryData = @json($viewsByCountry ?? collect());
|
|
countryChartInst.options.onClick = function(evt, elements) {
|
|
if (!elements.length) return;
|
|
const row = countryData[elements[0].index];
|
|
if (!row) return;
|
|
openDashModal('Viewers from ' + (row.country_name || row.country), 'country_viewers', { country: row.country }, 'bi-globe2');
|
|
};
|
|
countryChartInst.update();
|
|
}
|
|
|
|
// ── Cleanup ─────────────────────────────────────────────────────
|
|
document.getElementById('cleanupBtn')?.addEventListener('click', async function() {
|
|
this.disabled = true;
|
|
this.innerHTML = '<i class="bi bi-gear-fill me-2"></i> Cleaning...';
|
|
const status = document.getElementById('cleanupStatus');
|
|
try {
|
|
const res = await fetch('{{ url("/admin/cleanup-orphans") }}', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content, 'X-Requested-With': 'XMLHttpRequest' }
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
status.innerHTML = `<div class="alert alert-success mt-2 py-2 px-3" style="font-size:13px;">✅ ${data.message} — Videos: ${data.stats.videos_dir_size_mb} MB | Total: ${data.stats.total_public_size_mb} MB</div>`;
|
|
setTimeout(() => location.reload(), 2500);
|
|
} else throw new Error(data.message || 'Failed');
|
|
} catch(e) {
|
|
status.innerHTML = `<div class="alert alert-danger mt-2 py-2 px-3" style="font-size:13px;">❌ ${e.message}</div>`;
|
|
}
|
|
this.disabled = false;
|
|
this.innerHTML = '<i class="bi bi-trash3-fill me-2"></i> Clean Orphaned Files';
|
|
});
|
|
</script>
|
|
@endsection
|