takeone-youtube-clone/resources/views/admin/video-analytics.blade.php
ghassan 0b2e95ea65 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 <noreply@anthropic.com>
2026-05-13 13:24:32 +03:00

591 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@extends('admin.layout')
@section('title', '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