ghassan c160242dbc WIP: storage-fix-local-nas work before playlist controls feature
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:15:20 +03:00

623 lines
26 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

@extends('admin.layout')
@section('title', 'Users')
@php
$adminNeedsOtp = auth()->user()->two_factor_enabled &&
(! session('admin_2fa_verified_at') || now()->timestamp - session('admin_2fa_verified_at') >= 1800);
$totalUsers = \App\Models\User::count();
$totalAdmins = \App\Models\User::whereIn('role', ['admin','super_admin'])->count();
$newThisWeek = \App\Models\User::where('created_at', '>=', now()->subDays(7))->count();
$unverified = \App\Models\User::whereNull('email_verified_at')->count();
@endphp
@section('extra_styles')
<style>
/* ═══ STAT CARDS ═══════════════════════════════════════════════════ */
.u-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.u-stat {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 20px 22px;
position: relative;
overflow: hidden;
transition: border-color .2s, transform .2s;
}
.u-stat:hover { border-color: #3a3a3a; transform: translateY(-1px); }
.u-stat-top { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 14px; }
.u-stat-icon {
width: 40px; height: 40px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 17px; flex-shrink: 0;
}
.u-stat-icon.c-blue { background: rgba(96,165,250,.14); color: #60a5fa; }
.u-stat-icon.c-red { background: rgba(248,113,113,.14); color: #f87171; }
.u-stat-icon.c-green { background: rgba(52,211,153,.14); color: #34d399; }
.u-stat-icon.c-amber { background: rgba(251,191,36,.14); color: #fbbf24; }
.u-stat-accent {
position: absolute; top: 0; left: 0; right: 0; height: 2px;
border-radius: 14px 14px 0 0;
}
.u-stat-val { font-size: 32px; font-weight: 700; line-height: 1; color: var(--text-primary); letter-spacing: -1px; }
.u-stat-lbl { font-size: 13px; color: var(--text-secondary); margin-top: 4px; font-weight: 500; }
/* ═══ FILTER BAR ══════════════════════════════════════════════════ */
.u-filter {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 14px 18px;
}
.u-search {
position: relative; flex: 1; min-width: 200px;
}
.u-search i {
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
font-size: 13px; color: var(--text-secondary); pointer-events: none;
}
.u-search input {
width: 100%; height: 38px;
background: var(--bg-card2, #1a1a1a);
border: 1px solid var(--border-color); border-radius: 9px;
padding: 0 14px 0 36px; color: var(--text-primary); font-size: 13px;
transition: border-color .15s;
}
.u-search input:focus { outline: none; border-color: var(--brand-red); }
.u-search input::placeholder { color: #444; }
/* ═══ TABLE ════════════════════════════════════════════════════════ */
.u-table-wrap { overflow-x: auto; }
.u-table {
width: 100%; border-collapse: collapse; min-width: 640px;
}
.u-table thead tr {
border-bottom: 1px solid var(--border-color);
}
.u-table thead th {
padding: 11px 18px;
font-size: 11px; font-weight: 600; letter-spacing: .7px;
text-transform: uppercase; color: var(--text-secondary);
white-space: nowrap; text-align: left;
}
.u-table thead th.right { text-align: right; }
.u-table tbody tr {
border-bottom: 1px solid rgba(255,255,255,.04);
cursor: pointer;
transition: background .1s;
}
.u-table tbody tr:last-child { border-bottom: none; }
.u-table tbody tr:hover { background: rgba(255,255,255,.03); }
.u-table tbody td {
padding: 0 18px; height: 68px;
vertical-align: middle; font-size: 13px;
}
/* Identity cell */
.u-identity { display: flex; align-items: center; gap: 13px; }
.u-avatar-wrap { position: relative; flex-shrink: 0; }
.u-avatar {
width: 40px; height: 40px; border-radius: 50%;
object-fit: cover; display: block;
border: 2px solid var(--border-color);
transition: border-color .15s;
}
.u-table tbody tr:hover .u-avatar { border-color: var(--brand-red); }
.u-name {
font-size: 14px; font-weight: 600; color: var(--text-primary);
white-space: nowrap; display: flex; align-items: center; gap: 7px;
line-height: 1;
}
.u-email { font-size: 12px; color: #555; margin-top: 3px; }
.u-you {
font-size: 9px; font-weight: 700; letter-spacing: .5px;
padding: 2px 6px; border-radius: 4px;
background: rgba(230,30,30,.15); color: var(--brand-red);
text-transform: uppercase; flex-shrink: 0;
}
/* Role cell */
.u-role { display: flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 500; white-space: nowrap; }
.u-role-dot {
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
}
.u-role-dot.super { background: #f87171; box-shadow: 0 0 6px rgba(248,113,113,.5); }
.u-role-dot.admin { background: #fbbf24; box-shadow: 0 0 6px rgba(251,191,36,.4); }
.u-role-dot.user { background: #555; }
/* Status cell */
.u-status { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 500; white-space: nowrap; }
.u-status-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.u-status.verified { color: #34d399; }
.u-status.verified .u-status-dot { background: #34d399; box-shadow: 0 0 5px rgba(52,211,153,.5); }
.u-status.unverified { color: #fbbf24; }
.u-status.unverified .u-status-dot { background: #fbbf24; }
/* Videos cell */
.u-vcount { font-size: 15px; font-weight: 700; color: var(--text-primary); }
.u-vcount-lbl { font-size: 11px; color: #444; }
/* Joined cell */
.u-joined-date { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.u-joined-ago { font-size: 11px; color: #444; margin-top: 2px; }
/* Row click hint */
.u-table tbody tr:hover td:last-child::after {
content: '⋯';
float: right;
color: #444;
font-size: 18px;
line-height: 1;
}
/* Row dropdown */
#uRowMenu {
display: none;
position: fixed;
z-index: 9999;
min-width: 190px;
background: #1c1c1c;
border: 1px solid #2e2e2e;
border-radius: 10px;
padding: 5px 0;
box-shadow: 0 12px 40px rgba(0,0,0,.7);
animation: uMenuIn .1s ease;
}
@keyframes uMenuIn {
from { opacity:0; transform:translateY(-4px); }
to { opacity:1; transform:translateY(0); }
}
#uRowMenu .u-menu-item {
display: flex; align-items: center; gap: 10px;
width: 100%; padding: 9px 14px;
background: none; border: none;
color: var(--text-primary); font-size: 13px;
cursor: pointer; text-align: left; text-decoration: none;
transition: background .1s;
white-space: nowrap;
}
#uRowMenu .u-menu-item:hover { background: rgba(255,255,255,.06); }
#uRowMenu .u-menu-item.danger { color: #f87171; }
#uRowMenu .u-menu-item.danger:hover { background: rgba(248,113,113,.1); }
#uRowMenu .u-menu-sep {
height: 1px; background: #2a2a2a; margin: 4px 0;
}
/* Empty state */
.u-empty { padding: 80px 20px; text-align: center; }
.u-empty-circle {
width: 72px; height: 72px; border-radius: 50%;
background: rgba(255,255,255,.03); border: 1px solid var(--border-color);
margin: 0 auto 18px;
display: flex; align-items: center; justify-content: center;
font-size: 30px; color: #333;
}
.u-empty h3 { font-size: 16px; font-weight: 600; color: var(--text-secondary); margin: 0 0 6px; }
.u-empty p { font-size: 13px; color: #444; margin: 0; }
/* ═══ RESPONSIVE ════════════════════════════════════════════════════ */
@media (max-width: 960px) {
.u-stats { grid-template-columns: repeat(2, 1fr); }
.u-hide-md { display: none !important; }
}
@media (max-width: 600px) {
.u-stats { grid-template-columns: repeat(2, 1fr); }
.u-hide-sm { display: none !important; }
}
</style>
@endsection
@section('content')
<div class="adm-page-header">
<h1 class="adm-page-title"><i class="bi bi-people"></i> Users</h1>
<a href="{{ route('admin.dashboard') }}" class="adm-btn" style="font-size:12px;">
<i class="bi bi-arrow-left"></i> Dashboard
</a>
</div>
{{-- Alerts --}}
@if(session('success'))
<div class="adm-alert adm-alert-success">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
@if(session('error'))
<div class="adm-alert adm-alert-error">
<i class="bi bi-exclamation-circle-fill"></i>
<span>{{ session('error') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
{{-- Stats --}}
<div class="u-stats">
<div class="u-stat">
<div class="u-stat-accent" style="background: linear-gradient(90deg,#60a5fa,#3b82f6);"></div>
<div class="u-stat-top">
<div>
<div class="u-stat-val">{{ number_format($totalUsers) }}</div>
<div class="u-stat-lbl">Total users</div>
</div>
<div class="u-stat-icon c-blue"><i class="bi bi-people-fill"></i></div>
</div>
</div>
<div class="u-stat">
<div class="u-stat-accent" style="background: linear-gradient(90deg,#f87171,#e61e1e);"></div>
<div class="u-stat-top">
<div>
<div class="u-stat-val">{{ $totalAdmins }}</div>
<div class="u-stat-lbl">Admins</div>
</div>
<div class="u-stat-icon c-red"><i class="bi bi-shield-fill"></i></div>
</div>
</div>
<div class="u-stat">
<div class="u-stat-accent" style="background: linear-gradient(90deg,#34d399,#059669);"></div>
<div class="u-stat-top">
<div>
<div class="u-stat-val">{{ $newThisWeek }}</div>
<div class="u-stat-lbl">New this week</div>
</div>
<div class="u-stat-icon c-green"><i class="bi bi-person-plus-fill"></i></div>
</div>
</div>
<div class="u-stat">
<div class="u-stat-accent" style="background: linear-gradient(90deg,#fbbf24,#d97706);"></div>
<div class="u-stat-top">
<div>
<div class="u-stat-val">{{ $unverified }}</div>
<div class="u-stat-lbl">Unverified</div>
</div>
<div class="u-stat-icon c-amber"><i class="bi bi-envelope-exclamation-fill"></i></div>
</div>
</div>
</div>
{{-- Filter --}}
<div class="adm-card" style="margin-bottom:16px;">
<form method="GET" action="{{ route('admin.users') }}" class="u-filter">
<div class="u-search">
<i class="bi bi-search"></i>
<input type="text" name="search" placeholder="Search by 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' : '' }}>Users</option>
<option value="admin" {{ request('role') === 'admin' ? 'selected' : '' }}>Admins</option>
<option value="super_admin" {{ request('role') === 'super_admin' ? 'selected' : '' }}>Super Admins</option>
</select>
<select name="sort" class="adm-select">
<option value="latest" {{ request('sort','latest') === 'latest' ? 'selected' : '' }}>Newest first</option>
<option value="oldest" {{ request('sort') === 'oldest' ? 'selected' : '' }}>Oldest first</option>
<option value="name_asc" {{ request('sort') === 'name_asc' ? 'selected' : '' }}>Name AZ</option>
<option value="name_desc" {{ request('sort') === 'name_desc' ? 'selected' : '' }}>Name ZA</option>
</select>
<button type="submit" class="adm-btn adm-btn-primary"><i class="bi bi-funnel-fill"></i> Filter</button>
@if(request()->hasAny(['search','role','sort']))
<a href="{{ route('admin.users') }}" class="adm-btn"><i class="bi bi-x-lg"></i> Clear</a>
@endif
</form>
</div>
{{-- Table --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title">
<i class="bi bi-people"></i> Members
<span class="adm-badge adm-badge-user">{{ $users->total() ?? $users->count() }}</span>
</div>
<span style="font-size:12px; color:#444; display:flex; align-items:center; gap:5px;">
<i class="bi bi-hand-index" style="font-size:11px;"></i>
Click a row for actions
</span>
</div>
<div class="u-table-wrap">
<table class="u-table">
<thead>
<tr>
<th>User</th>
<th class="u-hide-sm">Role</th>
<th class="u-hide-sm">Status</th>
<th class="u-hide-md">Videos</th>
<th class="u-hide-md">Likes</th>
<th class="u-hide-md">Shares</th>
<th class="u-hide-md">Joined</th>
</tr>
</thead>
<tbody>
@forelse($users as $user)
<tr class="u-clickable-row"
data-channel="{{ route('channel', $user->channel) }}"
data-user-id="{{ $user->id }}"
data-user-name="{{ addslashes($user->name) }}"
data-verified="{{ $user->email_verified_at ? '1' : '0' }}"
data-is-self="{{ $user->id === auth()->id() ? '1' : '0' }}"
data-is-super="{{ $user->isSuperAdmin() ? '1' : '0' }}"
data-edit-url="{{ route('admin.users.edit', $user->id) }}"
data-verify-url="{{ route('admin.users.verify', $user->id) }}"
data-impersonate-url="{{ route('admin.users.impersonate', $user->id) }}"
data-delete-id="{{ $user->id }}">
{{-- Identity --}}
<td>
<div class="u-identity">
<div class="u-avatar-wrap">
<img class="u-avatar" src="{{ $user->avatar_url }}" alt="{{ $user->name }}">
</div>
<div>
<div class="u-name">
{{ $user->name }}
@if($user->id === auth()->id())
<span class="u-you">you</span>
@endif
</div>
<div class="u-email">{{ $user->email }}</div>
</div>
</div>
</td>
{{-- Role --}}
<td class="u-hide-sm">
@if($user->role === 'super_admin')
<div class="u-role"><span class="u-role-dot super"></span> Super Admin</div>
@elseif($user->role === 'admin')
<div class="u-role"><span class="u-role-dot admin"></span> Admin</div>
@else
<div class="u-role" style="color:#666;"><span class="u-role-dot user"></span> User</div>
@endif
</td>
{{-- Status --}}
<td class="u-hide-sm">
@if($user->email_verified_at)
<div class="u-status verified">
<span class="u-status-dot"></span> Verified
</div>
@else
<div class="u-status unverified">
<span class="u-status-dot"></span> Pending
</div>
@endif
</td>
{{-- Videos --}}
<td class="u-hide-md">
<div class="u-vcount">{{ $user->videos->count() }}</div>
<div class="u-vcount-lbl">videos</div>
</td>
{{-- Likes --}}
<td class="u-hide-md">
@php $videoIds = $user->videos->pluck('id'); @endphp
<div class="u-vcount">{{ number_format(\DB::table('video_likes')->whereIn('video_id', $videoIds)->count()) }}</div>
<div class="u-vcount-lbl">likes</div>
</td>
{{-- Shares --}}
<td class="u-hide-md">
<div class="u-vcount">{{ number_format(\DB::table('video_shares')->whereIn('video_id', $videoIds)->count()) }}</div>
<div class="u-vcount-lbl">shares</div>
</td>
{{-- Joined --}}
<td class="u-hide-md">
<div class="u-joined-date">{{ $user->created_at->format('M d, Y') }}</div>
<div class="u-joined-ago">{{ $user->created_at->diffForHumans() }}</div>
</td>
</tr>
@empty
<tr>
<td colspan="7" style="border:none;">
<div class="u-empty">
<div class="u-empty-circle"><i class="bi bi-people"></i></div>
<h3>No users found</h3>
<p>Try adjusting your search or filter criteria</p>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($users instanceof \Illuminate\Pagination\LengthAwarePaginator && $users->hasPages())
<div style="padding:14px 20px; border-top:1px solid var(--border-color);">
{{ $users->onEachSide(1)->links() }}
</div>
@endif
</div>
{{-- Row action dropdown --}}
<div id="uRowMenu"></div>
{{-- Delete 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>
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDeleteDialog()"
style="border:none;background:none;color:var(--text-secondary);">
<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>
@if($adminNeedsOtp)
<div style="margin-top:16px;">
<label style="font-size:13px;color:var(--text-secondary);display:flex;align-items:center;gap:6px;margin-bottom:8px;">
<i class="bi bi-shield-lock-fill" style="color:#a78bfa;"></i>
Enter your <strong style="color:var(--text-primary);">2FA code</strong> to confirm
</label>
<input type="text" id="dlgUserOtp" inputmode="numeric" pattern="[0-9]*"
maxlength="6" autocomplete="one-time-code" placeholder="000 000"
style="width:100%;background:#111;border:1px solid var(--border-color);border-radius:8px;padding:12px 16px;color:#fff;font-size:26px;letter-spacing:.25em;text-align:center;transition:border-color .15s;"
onfocus="this.style.borderColor='#a78bfa'" onblur="this.style.borderColor='var(--border-color)'">
</div>
@endif
<div id="dlgUserError" style="display:none;margin-top:10px;padding:10px 14px;background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.25);border-radius:8px;color:#f87171;font-size:13px;"></div>
</div>
<div class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDeleteDialog()">Cancel</button>
<button type="button" class="adm-btn adm-btn-danger" id="dlgUserConfirmBtn" onclick="confirmDeleteUser()"
style="height:36px;">
<i class="bi bi-trash"></i> Delete permanently
</button>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
const _adminNeedsOtp = {{ $adminNeedsOtp ? 'true' : 'false' }};
let _deleteUserId = null;
/* ── Row dropdown ─────────────────────────────────────────────── */
const _menu = document.getElementById('uRowMenu');
function _closeMenu() { _menu.style.display = 'none'; }
document.querySelectorAll('.u-clickable-row').forEach(function(row) {
row.addEventListener('click', function(e) {
e.stopPropagation();
const d = row.dataset;
const isSelf = d.isSelf === '1';
const isSuper = d.isSuper === '1';
const verified = d.verified === '1';
let html = '';
// Open channel
html += `<a class="u-menu-item" href="${d.channel}" target="_blank"><i class="bi bi-box-arrow-up-right"></i> Open Channel</a>`;
html += `<a class="u-menu-item" href="${d.editUrl}"><i class="bi bi-pencil"></i> Edit User</a>`;
if (!verified) {
html += `<button class="u-menu-item" onclick="_verifyUser('${d.verifyUrl}')"><i class="bi bi-patch-check-fill" style="color:#34d399;"></i> Verify Account</button>`;
}
if (!isSelf && !isSuper) {
html += `<button class="u-menu-item" onclick="_impersonateUser('${d.impersonateUrl}')"><i class="bi bi-person-fill-gear" style="color:#60a5fa;"></i> Impersonate</button>`;
}
if (!isSelf) {
html += `<div class="u-menu-sep"></div>`;
html += `<button class="u-menu-item danger" onclick="_closeMenu(); openDeleteDialog(${d.deleteId}, '${d.userName}')"><i class="bi bi-trash"></i> Delete User</button>`;
}
_menu.innerHTML = html;
// Position near click, keep within viewport
const vw = window.innerWidth, vh = window.innerHeight;
const mw = 200, mh = _menu.childElementCount * 40;
let x = e.clientX, y = e.clientY + 6;
if (x + mw > vw - 12) x = vw - mw - 12;
if (y + mh > vh - 12) y = e.clientY - mh - 6;
_menu.style.left = x + 'px';
_menu.style.top = y + 'px';
_menu.style.display = 'block';
});
});
document.addEventListener('click', _closeMenu);
document.addEventListener('keydown', e => { if (e.key === 'Escape') { _closeMenu(); closeDeleteDialog(); } });
function _verifyUser(url) {
_closeMenu();
const f = document.createElement('form');
f.method = 'POST'; f.action = url;
f.innerHTML = '<input type="hidden" name="_token" value="{{ csrf_token() }}">';
document.body.appendChild(f); f.submit();
}
function _impersonateUser(url) {
_closeMenu();
const f = document.createElement('form');
f.method = 'POST'; f.action = url;
f.innerHTML = '<input type="hidden" name="_token" value="{{ csrf_token() }}">';
document.body.appendChild(f); f.submit();
}
/* ── Delete dialog ────────────────────────────────────────────── */
function openDeleteDialog(userId, userName) {
_deleteUserId = userId;
document.getElementById('dlgUserName').textContent = userName;
document.getElementById('dlgUserError').style.display = 'none';
if (_adminNeedsOtp) document.getElementById('dlgUserOtp').value = '';
document.getElementById('deleteDialog').classList.add('open');
if (_adminNeedsOtp) setTimeout(() => document.getElementById('dlgUserOtp').focus(), 120);
}
function closeDeleteDialog() {
document.getElementById('deleteDialog').classList.remove('open');
_deleteUserId = null;
}
function confirmDeleteUser() {
if (!_deleteUserId) return;
const btn = document.getElementById('dlgUserConfirmBtn');
const errEl = document.getElementById('dlgUserError');
const otp = _adminNeedsOtp ? document.getElementById('dlgUserOtp').value.replace(/\s/g,'') : '';
if (_adminNeedsOtp && otp.length !== 6) {
errEl.textContent = 'Please enter your 6-digit 2FA code.';
errEl.style.display = 'block';
document.getElementById('dlgUserOtp').focus();
return;
}
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Deleting…';
errEl.style.display = 'none';
const body = new URLSearchParams({ _token: '{{ csrf_token() }}', _method: 'DELETE' });
if (_adminNeedsOtp) body.append('otp_code', otp);
fetch('/admin/users/' + _deleteUserId, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
})
.then(r => r.json())
.then(data => {
if (data.success) { closeDeleteDialog(); window.location.reload(); }
else {
errEl.textContent = data.message || 'Delete failed.';
errEl.style.display = 'block';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i> Delete permanently';
if (_adminNeedsOtp) { document.getElementById('dlgUserOtp').value = ''; document.getElementById('dlgUserOtp').focus(); }
}
})
.catch(() => {
errEl.textContent = 'An unexpected error occurred.';
errEl.style.display = 'block';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i> Delete permanently';
});
}
document.getElementById('deleteDialog').addEventListener('click', e => {
if (e.target === document.getElementById('deleteDialog')) closeDeleteDialog();
});
</script>
@endsection