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

@@ -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