Add NAS file manager integration and all pending platform changes

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

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

No files matched your search

+385
View File
@@ -0,0 +1,385 @@
@extends('admin.layout')
@section('title', 'Audit Logs')
@section('content')
<style>
.al-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 20px; flex-wrap: wrap; gap: 12px;
}
.al-title { font-size: 22px; font-weight: 600; display: flex; align-items: center; gap: 10px; }
.al-title i { color: var(--brand); }
/* Filter bar */
.al-filters {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 20px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
align-items: end;
}
.al-filter-group label {
display: block; font-size: 11px; font-weight: 700;
letter-spacing: .7px; text-transform: uppercase;
color: var(--text-muted); margin-bottom: 6px;
}
.al-filter-input {
width: 100%; background: var(--bg-body); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 12px; color: var(--text);
font-size: 13px; line-height: 1;
}
.al-filter-input:focus { outline: none; border-color: var(--brand); }
.al-filter-input option { background: #1a1a1a; }
.al-filter-btn {
display: flex; gap: 8px;
}
.al-btn {
padding: 9px 16px; border-radius: 8px; font-size: 13px;
font-weight: 600; cursor: pointer; border: none; white-space: nowrap;
}
.al-btn-primary { background: var(--brand); color: #fff; }
.al-btn-ghost { background: var(--border-light); color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 6px; }
.al-btn-ghost:hover { background: var(--border); color: var(--text); }
/* Stats strip */
.al-stats {
display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap;
}
.al-stat {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 10px; padding: 10px 18px; font-size: 13px;
display: flex; align-items: center; gap: 8px;
}
.al-stat strong { font-size: 18px; font-weight: 700; }
.al-stat-label { color: var(--text-muted); font-size: 12px; }
/* Table */
.al-table-wrap {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.al-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.al-table thead tr {
background: rgba(255,255,255,.03);
border-bottom: 1px solid var(--border);
}
.al-table th {
padding: 11px 14px; text-align: left;
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: .6px; color: var(--text-muted); white-space: nowrap;
}
.al-table td { padding: 10px 14px; border-bottom: 1px solid rgba(255,255,255,.04); vertical-align: middle; }
.al-table tr:last-child td { border-bottom: none; }
.al-table tbody tr:hover { background: rgba(255,255,255,.02); }
/* Severity badges */
.al-badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 10px; border-radius: 20px;
font-size: 11px; font-weight: 700; white-space: nowrap;
}
.al-badge-danger { background: rgba(239,68,68,.15); color: #f87171; border: 1px solid rgba(239,68,68,.3); }
.al-badge-warning { background: rgba(251,191,36,.15); color: #fbbf24; border: 1px solid rgba(251,191,36,.3); }
.al-badge-success { background: rgba(34,197,94,.15); color: #4ade80; border: 1px solid rgba(34,197,94,.3); }
.al-badge-info { background: rgba(96,165,250,.15); color: #60a5fa; border: 1px solid rgba(96,165,250,.3); }
.al-badge-purple { background: rgba(167,139,250,.15);color: #a78bfa; border: 1px solid rgba(167,139,250,.3); }
.al-badge-orange { background: rgba(251,146,60,.15); color: #fb923c; border: 1px solid rgba(251,146,60,.3); }
.al-badge-muted { background: rgba(255,255,255,.06); color: var(--text-muted); border: 1px solid var(--border); }
.al-badge-default { background: rgba(255,255,255,.06); color: var(--text); border: 1px solid var(--border); }
/* Icon per severity */
.al-badge-danger i { color: #f87171; }
.al-badge-warning i { color: #fbbf24; }
.al-badge-success i { color: #4ade80; }
.al-badge-info i { color: #60a5fa; }
/* User cell */
.al-user { display: flex; align-items: center; gap: 8px; }
.al-user-avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.al-user-name { font-weight: 600; }
.al-user-id { font-size: 11px; color: var(--text-muted); }
/* IP cell */
.al-ip { font-family: monospace; font-size: 12px; color: var(--text-muted); }
.al-ip a { color: inherit; text-decoration: underline dotted; }
/* Subject cell */
.al-subject { max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Details expand */
.al-details-btn {
background: none; border: none; color: var(--text-muted);
cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 12px;
}
.al-details-btn:hover { background: var(--border-light); color: var(--text); }
.al-details-row td {
padding: 0 !important;
border-bottom: 1px solid rgba(255,255,255,.04) !important;
}
.al-details-inner {
padding: 10px 14px 14px 44px;
font-family: monospace; font-size: 12px;
color: var(--text-muted); white-space: pre-wrap;
background: rgba(0,0,0,.15);
}
/* Pagination */
.al-pagination {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; border-top: 1px solid var(--border);
font-size: 13px; color: var(--text-muted); flex-wrap: wrap; gap: 10px;
}
.al-page-links { display: flex; gap: 4px; }
.al-page-links a, .al-page-links span {
padding: 5px 10px; border-radius: 6px; font-size: 13px;
border: 1px solid var(--border); color: var(--text-muted); text-decoration: none;
}
.al-page-links a:hover { background: var(--border-light); color: var(--text); }
.al-page-links span.active { background: var(--brand); border-color: var(--brand); color: #fff; }
.al-page-links span.disabled { opacity: .4; cursor: not-allowed; }
/* Empty state */
.al-empty {
padding: 60px 20px; text-align: center; color: var(--text-muted);
}
.al-empty i { font-size: 48px; display: block; margin-bottom: 12px; }
</style>
<div class="al-header">
<div class="al-title">
<i class="bi bi-shield-check"></i> Audit Logs
</div>
<div style="font-size:13px;color:var(--text-muted);">
{{ $logs->total() }} events found
@if(request()->hasAny(['action','user','ip','subject','date_from','date_to']))
— <a href="{{ route('admin.audit') }}" style="color:var(--brand);">clear filters</a>
@endif
</div>
</div>
{{-- Filters --}}
<form method="GET" action="{{ route('admin.audit') }}">
<div class="al-filters">
<div class="al-filter-group">
<label>Action Type</label>
<select name="action" class="al-filter-input">
<option value="">All Actions</option>
@foreach($actionTypes as $type)
<option value="{{ $type }}" {{ request('action') === $type ? 'selected' : '' }}>
{{ ucwords(str_replace(['.','_'], ' ', $type)) }}
</option>
@endforeach
</select>
</div>
<div class="al-filter-group">
<label>User / Email</label>
<input type="text" name="user" class="al-filter-input" value="{{ request('user') }}" placeholder="Name or email…">
</div>
<div class="al-filter-group">
<label>IP Address</label>
<input type="text" name="ip" class="al-filter-input" value="{{ request('ip') }}" placeholder="e.g. 1.2.3.4">
</div>
<div class="al-filter-group">
<label>Subject</label>
<input type="text" name="subject" class="al-filter-input" value="{{ request('subject') }}" placeholder="Video title, username…">
</div>
<div class="al-filter-group">
<label>From Date</label>
<input type="date" name="date_from" class="al-filter-input" value="{{ request('date_from') }}">
</div>
<div class="al-filter-group">
<label>To Date</label>
<input type="date" name="date_to" class="al-filter-input" value="{{ request('date_to') }}">
</div>
<div class="al-filter-group al-filter-btn">
<button type="submit" class="al-btn al-btn-primary"><i class="bi bi-funnel-fill"></i> Filter</button>
<a href="{{ route('admin.audit') }}" class="al-btn al-btn-ghost"><i class="bi bi-x-lg"></i></a>
</div>
</div>
</form>
{{-- Table --}}
<div class="al-table-wrap">
@if($logs->isEmpty())
<div class="al-empty">
<i class="bi bi-shield-check"></i>
No audit events found.
</div>
@else
<table class="al-table" id="auditTable">
<thead>
<tr>
<th>Time</th>
<th>Action</th>
<th>User</th>
<th>Subject</th>
<th>IP Address</th>
<th>Device</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach($logs as $log)
@php
$severity = $log->severity;
$icon = match($severity) {
'danger' => 'bi-trash3-fill',
'warning' => 'bi-exclamation-triangle-fill',
'success' => 'bi-cloud-upload-fill',
'info' => 'bi-box-arrow-in-right',
'purple' => 'bi-shield-lock-fill',
'orange' => 'bi-person-badge-fill',
'muted' => 'bi-box-arrow-right',
default => 'bi-dot',
};
$ua = $log->user_agent ?? '';
$device = match(true) {
str_contains($ua, 'Mobile') || str_contains($ua, 'Android') => '📱 Mobile',
str_contains($ua, 'Tablet') || str_contains($ua, 'iPad') => '📟 Tablet',
default => '🖥️ Desktop',
};
$browser = match(true) {
str_contains($ua, 'Firefox') => 'Firefox',
str_contains($ua, 'Chrome') && !str_contains($ua, 'Chromium') && !str_contains($ua, 'Edg') => 'Chrome',
str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome') => 'Safari',
str_contains($ua, 'Edg') => 'Edge',
str_contains($ua, 'curl') => 'cURL',
default => 'Unknown',
};
@endphp
<tr>
<td style="white-space:nowrap;">
<div style="font-weight:600;font-size:13px;">{{ $log->created_at->format('d M Y') }}</div>
<div style="font-size:11px;color:var(--text-muted);">{{ $log->created_at->format('H:i:s') }}</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:1px;" title="{{ $log->created_at }}">{{ $log->created_at->diffForHumans() }}</div>
</td>
<td>
<span class="al-badge al-badge-{{ $severity }}">
<i class="bi {{ $icon }}"></i>
{{ $log->action_label }}
</span>
</td>
<td>
<div class="al-user">
@if($log->user)
<img src="{{ $log->user->avatar_url }}" alt="" class="al-user-avatar">
@else
<div class="al-user-avatar" style="background:var(--border);display:flex;align-items:center;justify-content:center;font-size:12px;">
<i class="bi bi-person" style="color:var(--text-muted);"></i>
</div>
@endif
<div>
<div class="al-user-name">
@if($log->user)
<a href="{{ route('admin.users.edit', $log->user) }}" style="color:inherit;text-decoration:none;" onmouseover="this.style.textDecoration='underline'" onmouseout="this.style.textDecoration='none'">
{{ $log->user_name ?? 'Unknown' }}
</a>
@else
{{ $log->user_name ?? ($log->action === 'user.login.failed' ? 'Guest' : 'Unknown') }}
@endif
</div>
@if($log->user_id)
<div class="al-user-id">#{{ $log->user_id }}</div>
@endif
</div>
</div>
</td>
<td>
@if($log->subject_label)
<div class="al-subject" title="{{ $log->subject_label }}">
@if($log->subject_type === 'Video' && $log->subject_id)
<a href="{{ route('videos.show', \App\Models\Video::encodeId((int)$log->subject_id)) }}" style="color:var(--brand);text-decoration:none;" target="_blank">
{{ $log->subject_label }}
</a>
@elseif($log->subject_type === 'User' && $log->subject_id)
<a href="{{ route('admin.users.edit', $log->subject_id) }}" style="color:var(--brand);text-decoration:none;">
{{ $log->subject_label }}
</a>
@else
{{ $log->subject_label }}
@endif
</div>
<div style="font-size:11px;color:var(--text-muted);">{{ $log->subject_type }}</div>
@else
<span style="color:var(--text-muted);">—</span>
@endif
</td>
<td>
<div class="al-ip">
@if($log->ip_address)
<a href="{{ route('admin.audit', ['ip' => $log->ip_address]) }}" title="Filter by this IP">
{{ $log->ip_address }}
</a>
@else
<span>—</span>
@endif
</div>
</td>
<td style="font-size:12px;color:var(--text-muted);white-space:nowrap;">
{{ $device }} · {{ $browser }}
</td>
<td>
@if($log->details)
<button class="al-details-btn" onclick="toggleDetails({{ $log->id }})" title="View details">
<i class="bi bi-code-slash"></i>
</button>
@endif
</td>
</tr>
@if($log->details)
<tr class="al-details-row" id="details-{{ $log->id }}" style="display:none;">
<td colspan="7">
<div class="al-details-inner">{{ json_encode($log->details, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</div>
</td>
</tr>
@endif
@endforeach
</tbody>
</table>
{{-- Pagination --}}
@if($logs->hasPages())
<div class="al-pagination">
<div>
Showing {{ $logs->firstItem() }}–{{ $logs->lastItem() }} of {{ $logs->total() }} events
</div>
<div class="al-page-links">
@if($logs->onFirstPage())
<span class="disabled"><i class="bi bi-chevron-left"></i></span>
@else
<a href="{{ $logs->previousPageUrl() }}"><i class="bi bi-chevron-left"></i></a>
@endif
@foreach($logs->getUrlRange(max(1, $logs->currentPage()-2), min($logs->lastPage(), $logs->currentPage()+2)) as $page => $url)
@if($page == $logs->currentPage())
<span class="active">{{ $page }}</span>
@else
<a href="{{ $url }}">{{ $page }}</a>
@endif
@endforeach
@if($logs->hasMorePages())
<a href="{{ $logs->nextPageUrl() }}"><i class="bi bi-chevron-right"></i></a>
@else
<span class="disabled"><i class="bi bi-chevron-right"></i></span>
@endif
</div>
</div>
@endif
@endif
</div>
<script>
function toggleDetails(id) {
const row = document.getElementById('details-' + id);
if (row) row.style.display = row.style.display === 'none' ? 'table-row' : 'none';
}
</script>
@endsection
File diff suppressed because it is too large. Load diff
+272 -93
View File
@@ -3,131 +3,310 @@
@section('title', 'Edit User')
@section('page_title', 'Edit User')
@section('extra_styles')
<style>
.ef-grid {
display: grid;
grid-template-columns: 1fr 320px;
gap: 20px;
align-items: start;
}
@media (max-width: 900px) {
.ef-grid { grid-template-columns: 1fr; }
}
/* ── Form fields ── */
.ef-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; }
.ef-field:last-of-type { margin-bottom: 0; }
.ef-label {
font-size: 12px; font-weight: 600; color: var(--text-2);
text-transform: uppercase; letter-spacing: .04em;
}
.ef-input, .ef-select, .ef-textarea {
width: 100%;
background: var(--bg-2);
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
font-size: 13px;
outline: none;
transition: border-color .15s;
font-family: inherit;
}
.ef-input, .ef-select { height: 38px; padding: 0 12px; }
.ef-select { cursor: pointer; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23888' viewBox='0 0 16 16'%3E%3Cpath d='M7.247 11.14L2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px;
}
.ef-textarea { height: 90px; padding: 10px 12px; resize: vertical; }
.ef-input:focus, .ef-select:focus, .ef-textarea:focus { border-color: var(--brand); }
.ef-input.is-invalid, .ef-select.is-invalid { border-color: #f87171; }
.ef-error { font-size: 12px; color: #f87171; margin-top: 2px; }
.ef-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 500px) { .ef-row { grid-template-columns: 1fr; } }
/* ── Section divider ── */
.ef-section {
margin: 24px 0 20px;
padding-top: 20px;
border-top: 1px solid var(--border);
display: flex; align-items: center; gap: 10px;
}
.ef-section-title {
font-size: 13px; font-weight: 600; color: var(--text-2);
white-space: nowrap;
}
.ef-section-hint { font-size: 12px; color: var(--text-3); }
/* ── Actions ── */
.ef-actions { display: flex; gap: 10px; margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border); }
/* ── Sidebar info card ── */
.ef-user-avatar {
width: 72px; height: 72px; border-radius: 50%; object-fit: cover;
border: 3px solid var(--border);
}
.ef-stat-row {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,.04);
font-size: 13px;
}
.ef-stat-row:last-child { border-bottom: none; padding-bottom: 0; }
.ef-stat-label { color: var(--text-2); }
.ef-stat-val { font-weight: 600; color: var(--text); }
/* ── Danger zone ── */
.ef-danger-zone {
margin-top: 16px;
padding: 16px;
border: 1px solid rgba(248,113,113,.25);
border-radius: 10px;
background: rgba(248,113,113,.05);
}
.ef-danger-title { font-size: 12px; font-weight: 700; color: #f87171; margin-bottom: 10px; text-transform: uppercase; letter-spacing: .05em; }
</style>
@endsection
@section('content')
<!-- Alerts -->
{{-- ── Page header ── --}}
<div class="adm-page-header" style="margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:12px;">
<a href="{{ route('admin.users') }}" class="adm-btn adm-btn-sm">
<i class="bi bi-arrow-left"></i>
</a>
<h1 class="adm-page-title" style="margin:0;">
<i class="bi bi-person-gear"></i> Edit User
</h1>
</div>
</div>
{{-- ── Alerts ── --}}
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="adm-alert adm-alert-success" style="margin-bottom:16px;">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
@if(session('error'))
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ session('error') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="adm-alert adm-alert-error" style="margin-bottom:16px;">
<i class="bi bi-exclamation-circle-fill"></i>
<span>{{ session('error') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
<div class="row">
<div class="col-lg-8">
<!-- Edit User Form -->
<div class="admin-card">
<div class="admin-card-header">
<h5 class="admin-card-title">Edit User</h5>
<a href="{{ route('admin.users') }}" class="btn btn-outline-light btn-sm">
<i class="bi bi-arrow-left"></i> Back to Users
</a>
</div>
<div class="ef-grid">
{{-- ── Left: edit form ── --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title"><i class="bi bi-pencil-square"></i> Account Details</div>
</div>
<div class="adm-card-body">
<form method="POST" action="{{ route('admin.users.update', $user->id) }}">
@csrf
@method('PUT')
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input type="text" class="form-control @error('name') is-invalid @enderror" id="name" name="name" value="{{ old('name', $user->name) }}" required>
@error('name')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- Name --}}
<div class="ef-field">
<label class="ef-label" for="name">Full Name</label>
<input class="ef-input @error('name') is-invalid @enderror"
id="name" name="name" type="text"
value="{{ old('name', $user->name) }}" required autocomplete="off">
@error('name')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control @error('email') is-invalid @enderror" id="email" name="email" value="{{ old('email', $user->email) }}" required>
@error('email')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- Email --}}
<div class="ef-field">
<label class="ef-label" for="email">Email Address</label>
<input class="ef-input @error('email') is-invalid @enderror"
id="email" name="email" type="email"
value="{{ old('email', $user->email) }}" required autocomplete="off">
@error('email')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="mb-3">
<label for="role" class="form-label">Role</label>
<select class="form-select @error('role') is-invalid @enderror" id="role" name="role" required>
<option value="user" {{ old('role', $user->role) == 'user' ? 'selected' : '' }}>User</option>
<option value="admin" {{ old('role', $user->role) == 'admin' ? 'selected' : '' }}>Admin</option>
<option value="super_admin" {{ old('role', $user->role) == 'super_admin' ? 'selected' : '' }}>Super Admin</option>
{{-- Role --}}
<div class="ef-field">
<label class="ef-label" for="role">Role</label>
<select class="ef-select @error('role') is-invalid @enderror" id="role" name="role">
<option value="user" {{ old('role', $user->role) === 'user' ? 'selected' : '' }}>User</option>
<option value="admin" {{ old('role', $user->role) === 'admin' ? 'selected' : '' }}>Admin</option>
<option value="super_admin" {{ old('role', $user->role) === 'super_admin' ? 'selected' : '' }}>Super Admin</option>
</select>
@error('role')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
@error('role')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<hr style="border-color: var(--border-color);">
<h6 class="mb-3">Change Password (Optional)</h6>
<div class="mb-3">
<label for="new_password" class="form-label">New Password</label>
<input type="password" class="form-control @error('new_password') is-invalid @enderror" id="new_password" name="new_password" placeholder="Leave blank to keep current password">
@error('new_password')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- Password section --}}
<div class="ef-section">
<span class="ef-section-title"><i class="bi bi-lock-fill" style="color:var(--brand);"></i> Change Password</span>
<span class="ef-section-hint">— leave blank to keep current password</span>
</div>
<div class="mb-3">
<label for="new_password_confirmation" class="form-label">Confirm New Password</label>
<input type="password" class="form-control" id="new_password_confirmation" name="new_password_confirmation" placeholder="Confirm new password">
<div class="ef-row">
<div class="ef-field" style="margin-bottom:0;">
<label class="ef-label" for="new_password">New Password</label>
<input class="ef-input @error('new_password') is-invalid @enderror"
id="new_password" name="new_password" type="password"
placeholder="Min. 8 characters" autocomplete="new-password">
@error('new_password')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="ef-field" style="margin-bottom:0;">
<label class="ef-label" for="new_password_confirmation">Confirm Password</label>
<input class="ef-input"
id="new_password_confirmation" name="new_password_confirmation"
type="password" placeholder="Repeat password" autocomplete="new-password">
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Update User
<div class="ef-actions">
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-check-circle-fill"></i> Save Changes
</button>
<a href="{{ route('admin.users') }}" class="btn btn-outline-light">Cancel</a>
<a href="{{ route('admin.users') }}" class="adm-btn">Cancel</a>
</div>
</form>
</div>
</div>
<div class="col-lg-4">
<!-- User Info -->
<div class="admin-card">
<div class="admin-card-header">
<h5 class="admin-card-title">User Info</h5>
</div>
<div class="text-center mb-3">
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="rounded-circle" style="width: 80px; height: 80px; object-fit: cover;">
<h5 class="mt-2">{{ $user->name }}</h5>
<p class="text-secondary mb-1">{{ $user->email }}</p>
{{-- ── Right: user info sidebar ── --}}
<div style="display:flex;flex-direction:column;gap:16px;">
{{-- Profile card --}}
<div class="adm-card">
<div class="adm-card-body" style="text-align:center;padding-top:28px;padding-bottom:24px;">
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="ef-user-avatar">
<div style="margin-top:12px;font-size:16px;font-weight:700;color:var(--text);">{{ $user->name }}</div>
<div style="font-size:12px;color:var(--text-2);margin:4px 0 10px;">{{ $user->email }}</div>
@if($user->role === 'super_admin')
<span class="badge-role badge-super-admin">Super Admin</span>
<span class="adm-badge adm-badge-superadmin">Super Admin</span>
@elseif($user->role === 'admin')
<span class="badge-role badge-admin">Admin</span>
<span class="adm-badge adm-badge-admin">Admin</span>
@else
<span class="badge-role badge-user">User</span>
<span class="adm-badge adm-badge-user">User</span>
@endif
</div>
<hr style="border-color: var(--border-color);">
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">User ID</span>
<span>#{{ $user->id }}</span>
</div>
{{-- Stats card --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title"><i class="bi bi-info-circle"></i> Account Info</div>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Joined</span>
<span>{{ $user->created_at->format('M d, Y') }}</span>
<div class="adm-card-body">
<div class="ef-stat-row">
<span class="ef-stat-label">User ID</span>
<span class="ef-stat-val">#{{ $user->id }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Joined</span>
<span class="ef-stat-val">{{ $user->created_at->format('M d, Y') }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Last active</span>
<span class="ef-stat-val">{{ $user->updated_at->diffForHumans() }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Videos</span>
<span class="ef-stat-val" style="color:#818cf8;">{{ $user->videos->count() }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Email verified</span>
@if($user->email_verified_at)
<span class="adm-badge adm-badge-verified" style="font-size:11px;">Verified</span>
@else
<span class="adm-badge adm-badge-unverified" style="font-size:11px;">Unverified</span>
@endif
</div>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Total Videos</span>
<span>{{ $user->videos->count() }}</span>
</div>
{{-- Impersonate --}}
@if(!$user->isSuperAdmin())
<form method="POST" action="{{ route('admin.users.impersonate', $user->id) }}">
@csrf
<button type="submit" class="adm-btn adm-btn-impersonate" style="width:100%;">
<i class="bi bi-person-badge"></i> Impersonate User
</button>
</form>
@endif
{{-- Danger zone --}}
@if(!$user->isSuperAdmin())
<div class="ef-danger-zone">
<div class="ef-danger-title"><i class="bi bi-exclamation-triangle-fill me-1"></i> Danger Zone</div>
<button type="button" class="adm-btn adm-btn-danger" style="width:100%;"
onclick="openDelDialog()">
<i class="bi bi-trash3-fill"></i> Delete This User
</button>
</div>
@endif
</div>
</div>
{{-- ── Delete confirmation dialog ── --}}
@if(!$user->isSuperAdmin())
<div class="adm-dialog-overlay" id="delDialog">
<div class="adm-dialog">
<div class="adm-dialog-header">
<div class="adm-dialog-title">
<i class="bi bi-exclamation-triangle-fill"></i> Delete User
</div>
<div class="d-flex justify-content-between">
<span class="text-secondary">Email Verified</span>
<span>{{ $user->email_verified_at ? 'Yes' : 'No' }}</span>
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDelDialog()"
style="border:none;background:none;color:var(--text-2);">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="adm-dialog-body">
<p>You are about to permanently delete <strong>{{ $user->name }}</strong>.</p>
<div class="adm-dialog-warning">
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
All videos uploaded by this user will also be deleted. This cannot be undone.
</div>
</div>
<div class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDelDialog()">Cancel</button>
<form method="POST" action="{{ route('admin.users.delete', $user->id) }}">
@csrf @method('DELETE')
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
<i class="bi bi-trash3-fill"></i> Delete Permanently
</button>
</form>
</div>
</div>
</div>
@endif
@endsection
@section('scripts')
<script>
function openDelDialog() { document.getElementById('delDialog').classList.add('open'); }
function closeDelDialog() { document.getElementById('delDialog').classList.remove('open'); }
document.getElementById('delDialog')?.addEventListener('click', e => { if (e.target === document.getElementById('delDialog')) closeDelDialog(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDelDialog(); });
</script>
@endsection
+276 -128
View File
@@ -3,164 +3,312 @@
@section('title', 'Edit Video')
@section('page_title', 'Edit Video')
@section('extra_styles')
<style>
.ef-grid {
display: grid;
grid-template-columns: 1fr 300px;
gap: 20px;
align-items: start;
}
@media (max-width: 900px) {
.ef-grid { grid-template-columns: 1fr; }
}
/* ── Form fields ── */
.ef-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; }
.ef-field:last-of-type { margin-bottom: 0; }
.ef-label {
font-size: 12px; font-weight: 600; color: var(--text-2);
text-transform: uppercase; letter-spacing: .04em;
}
.ef-input, .ef-select, .ef-textarea {
width: 100%;
background: var(--bg-2);
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
font-size: 13px;
outline: none;
transition: border-color .15s;
font-family: inherit;
}
.ef-input, .ef-select { height: 38px; padding: 0 12px; }
.ef-select { cursor: pointer; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23888' viewBox='0 0 16 16'%3E%3Cpath d='M7.247 11.14L2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px;
}
.ef-textarea { height: 100px; padding: 10px 12px; resize: vertical; }
.ef-input:focus, .ef-select:focus, .ef-textarea:focus { border-color: var(--brand); }
.ef-input.is-invalid, .ef-select.is-invalid, .ef-textarea.is-invalid { border-color: #f87171; }
.ef-error { font-size: 12px; color: #f87171; margin-top: 2px; }
.ef-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
@media (max-width: 600px) { .ef-row-3 { grid-template-columns: 1fr; } }
/* ── Actions ── */
.ef-actions { display: flex; gap: 10px; margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border); }
/* ── Sidebar stats ── */
.ef-thumb {
width: 100%; border-radius: 10px; object-fit: cover; display: block;
background: var(--bg-2);
}
.ef-thumb-placeholder {
width: 100%; height: 160px; border-radius: 10px;
background: var(--bg-2); border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center;
color: var(--text-3); font-size: 36px;
}
.ef-stat-row {
display: flex; justify-content: space-between; align-items: center;
padding: 9px 0; border-bottom: 1px solid rgba(255,255,255,.04);
font-size: 13px;
}
.ef-stat-row:last-child { border-bottom: none; padding-bottom: 0; }
.ef-stat-label { color: var(--text-2); }
.ef-stat-val { font-weight: 600; color: var(--text); }
/* ── Danger zone ── */
.ef-danger-zone {
padding: 16px;
border: 1px solid rgba(248,113,113,.25);
border-radius: 10px;
background: rgba(248,113,113,.05);
}
.ef-danger-title { font-size: 12px; font-weight: 700; color: #f87171; margin-bottom: 10px; text-transform: uppercase; letter-spacing: .05em; }
</style>
@endsection
@section('content')
<!-- Alerts -->
{{-- ── Page header ── --}}
<div class="adm-page-header" style="margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:12px;">
<a href="{{ route('admin.videos') }}" class="adm-btn adm-btn-sm">
<i class="bi bi-arrow-left"></i>
</a>
<h1 class="adm-page-title" style="margin:0;">
<i class="bi bi-pencil-square"></i> Edit Video
</h1>
</div>
</div>
{{-- ── Alerts ── --}}
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="adm-alert adm-alert-success" style="margin-bottom:16px;">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
@if(session('error'))
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ session('error') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="adm-alert adm-alert-error" style="margin-bottom:16px;">
<i class="bi bi-exclamation-circle-fill"></i>
<span>{{ session('error') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
<div class="row">
<div class="col-lg-8">
<!-- Edit Video Form -->
<div class="admin-card">
<div class="admin-card-header">
<h5 class="admin-card-title">Edit Video</h5>
<a href="{{ route('admin.videos') }}" class="btn btn-outline-light btn-sm">
<i class="bi bi-arrow-left"></i> Back to Videos
</a>
</div>
<form method="POST" action="{{ route('admin.videos.update', $video->id) }}">
<div class="ef-grid">
{{-- ── Left: form ── --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title"><i class="bi bi-film"></i> Video Details</div>
</div>
<div class="adm-card-body">
<form method="POST" action="{{ route('admin.videos.update', $video) }}">
@csrf
@method('PUT')
<div class="mb-3">
<label for="title" class="form-label">Title</label>
<input type="text" class="form-control @error('title') is-invalid @enderror" id="title" name="title" value="{{ old('title', $video->title) }}" required>
@error('title')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- Title --}}
<div class="ef-field">
<label class="ef-label" for="title">Title</label>
<input class="ef-input @error('title') is-invalid @enderror"
id="title" name="title" type="text"
value="{{ old('title', $video->title) }}" required>
@error('title')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control @error('description') is-invalid @enderror" id="description" name="description" rows="4">{{ old('description', $video->description) }}</textarea>
@error('description')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- Description --}}
<div class="ef-field">
<label class="ef-label" for="description">Description</label>
<textarea class="ef-textarea @error('description') is-invalid @enderror"
id="description" name="description">{{ old('description', $video->description) }}</textarea>
@error('description')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="row mb-3">
<div class="col-md-4">
<label for="visibility" class="form-label">Visibility</label>
<select class="form-select @error('visibility') is-invalid @enderror" id="visibility" name="visibility" required>
<option value="public" {{ old('visibility', $video->visibility) == 'public' ? 'selected' : '' }}>Public</option>
<option value="unlisted" {{ old('visibility', $video->visibility) == 'unlisted' ? 'selected' : '' }}>Unlisted</option>
<option value="private" {{ old('visibility', $video->visibility) == 'private' ? 'selected' : '' }}>Private</option>
{{-- Visibility / Type / Status --}}
<div class="ef-row-3">
<div class="ef-field" style="margin-bottom:0;">
<label class="ef-label" for="visibility">Visibility</label>
<select class="ef-select @error('visibility') is-invalid @enderror" id="visibility" name="visibility">
<option value="public" {{ old('visibility', $video->visibility) === 'public' ? 'selected' : '' }}>Public</option>
<option value="unlisted" {{ old('visibility', $video->visibility) === 'unlisted' ? 'selected' : '' }}>Unlisted</option>
<option value="private" {{ old('visibility', $video->visibility) === 'private' ? 'selected' : '' }}>Private</option>
</select>
@error('visibility')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
@error('visibility')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="col-md-4">
<label for="type" class="form-label">Type</label>
<select class="form-select @error('type') is-invalid @enderror" id="type" name="type" required>
<option value="generic" {{ old('type', $video->type) == 'generic' ? 'selected' : '' }}>Generic</option>
<option value="music" {{ old('type', $video->type) == 'music' ? 'selected' : '' }}>Music</option>
<option value="match" {{ old('type', $video->type) == 'match' ? 'selected' : '' }}>Match</option>
<div class="ef-field" style="margin-bottom:0;">
<label class="ef-label" for="type">Type</label>
<select class="ef-select @error('type') is-invalid @enderror" id="type" name="type">
<option value="generic" {{ old('type', $video->type) === 'generic' ? 'selected' : '' }}>Generic</option>
<option value="music" {{ old('type', $video->type) === 'music' ? 'selected' : '' }}>Music</option>
<option value="match" {{ old('type', $video->type) === 'match' ? 'selected' : '' }}>Match</option>
</select>
@error('type')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
@error('type')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="col-md-4">
<label for="status" class="form-label">Status</label>
<select class="form-select @error('status') is-invalid @enderror" id="status" name="status" required>
<option value="pending" {{ old('status', $video->status) == 'pending' ? 'selected' : '' }}>Pending</option>
<option value="processing" {{ old('status', $video->status) == 'processing' ? 'selected' : '' }}>Processing</option>
<option value="ready" {{ old('status', $video->status) == 'ready' ? 'selected' : '' }}>Ready</option>
<option value="failed" {{ old('status', $video->status) == 'failed' ? 'selected' : '' }}>Failed</option>
<div class="ef-field" style="margin-bottom:0;">
<label class="ef-label" for="status">Status</label>
<select class="ef-select @error('status') is-invalid @enderror" id="status" name="status">
<option value="pending" {{ old('status', $video->status) === 'pending' ? 'selected' : '' }}>Pending</option>
<option value="processing" {{ old('status', $video->status) === 'processing' ? 'selected' : '' }}>Processing</option>
<option value="ready" {{ old('status', $video->status) === 'ready' ? 'selected' : '' }}>Ready</option>
<option value="failed" {{ old('status', $video->status) === 'failed' ? 'selected' : '' }}>Failed</option>
</select>
@error('status')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
@error('status')<div class="ef-error">{{ $message }}</div>@enderror
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Update Video
{{-- Download Access --}}
<div class="ef-field" style="margin-top:18px;">
<label class="ef-label" for="download_access">Who Can Download</label>
<select class="ef-select @error('download_access') is-invalid @enderror" id="download_access" name="download_access">
<option value="disabled" {{ old('download_access', $video->download_access) === 'disabled' ? 'selected' : '' }}>No one (disabled)</option>
<option value="everyone" {{ old('download_access', $video->download_access) === 'everyone' ? 'selected' : '' }}>Everyone (guests too)</option>
<option value="registered" {{ old('download_access', $video->download_access) === 'registered' ? 'selected' : '' }}>Registered members</option>
<option value="subscribers" {{ old('download_access', $video->download_access) === 'subscribers' ? 'selected' : '' }}>Subscribers only</option>
</select>
@error('download_access')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="ef-actions">
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-check-circle-fill"></i> Save Changes
</button>
<a href="{{ route('admin.videos') }}" class="btn btn-outline-light">Cancel</a>
<a href="{{ route('admin.videos') }}" class="adm-btn">Cancel</a>
<a href="{{ route('videos.show', $video) }}" target="_blank" class="adm-btn" style="margin-left:auto;">
<i class="bi bi-play-circle"></i> View Video
</a>
</div>
</form>
</div>
</div>
<div class="col-lg-4">
<!-- Video Info -->
<div class="admin-card">
<div class="admin-card-header">
<h5 class="admin-card-title">Video Info</h5>
{{-- ── Right: sidebar ── --}}
<div style="display:flex;flex-direction:column;gap:16px;">
{{-- Thumbnail --}}
<div class="adm-card">
<div class="adm-card-body">
@if($video->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}"
alt="{{ $video->title }}" class="ef-thumb">
@else
<div class="ef-thumb-placeholder"><i class="bi bi-play-circle"></i></div>
@endif
</div>
@if($video->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}" class="img-fluid rounded mb-3" style="width: 100%;">
@else
<div class="bg-secondary rounded d-flex align-items-center justify-content-center mb-3" style="height: 180px;">
<i class="bi bi-play-circle text-white" style="font-size: 3rem;"></i>
</div>
{{-- Stats --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title"><i class="bi bi-info-circle"></i> Video Info</div>
</div>
<div class="adm-card-body">
<div class="ef-stat-row">
<span class="ef-stat-label">Owner</span>
<a href="{{ route('channel', $video->user->channel) }}" target="_blank"
style="color:var(--brand);font-weight:600;font-size:13px;text-decoration:none;">
{{ $video->user->name }}
</a>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Uploaded</span>
<span class="ef-stat-val">{{ $video->created_at->format('M d, Y') }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Views</span>
<span class="ef-stat-val" style="color:#22d3ee;">
{{ number_format(\DB::table('video_views')->where('video_id', $video->id)->count()) }}
</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Likes</span>
<span class="ef-stat-val" style="color:#f472b6;">
{{ number_format(\DB::table('video_likes')->where('video_id', $video->id)->count()) }}
</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Duration</span>
<span class="ef-stat-val">{{ $video->duration ? gmdate('H:i:s', $video->duration) : '—' }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Dimensions</span>
<span class="ef-stat-val">{{ $video->width ?? '—' }} × {{ $video->height ?? '—' }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">File size</span>
<span class="ef-stat-val">{{ $video->size ? number_format($video->size / 1024 / 1024, 1) . ' MB' : '—' }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Orientation</span>
<span class="ef-stat-val" style="text-transform:capitalize;">{{ $video->orientation ?? '—' }}</span>
</div>
@endif
<hr style="border-color: var(--border-color);">
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Video ID</span>
<span>#{{ $video->id }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Owner</span>
<a href="{{ route('channel', $video->user->id) }}" target="_blank">{{ $video->user->name }}</a>
</div>
{{-- Danger zone --}}
<div class="ef-danger-zone">
<div class="ef-danger-title"><i class="bi bi-exclamation-triangle-fill me-1"></i> Danger Zone</div>
<button type="button" class="adm-btn adm-btn-danger" style="width:100%;"
onclick="openDelDialog()">
<i class="bi bi-trash3-fill"></i> Delete This Video
</button>
</div>
</div>
</div>
{{-- ── Delete confirmation dialog ── --}}
<div class="adm-dialog-overlay" id="delDialog">
<div class="adm-dialog">
<div class="adm-dialog-header">
<div class="adm-dialog-title">
<i class="bi bi-exclamation-triangle-fill"></i> Delete Video
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Uploaded</span>
<span>{{ $video->created_at->format('M d, Y') }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">File Size</span>
<span>{{ number_format($video->size / 1024 / 1024, 2) }} MB</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Duration</span>
<span>{{ $video->duration ? gmdate('H:i:s', $video->duration) : 'N/A' }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Orientation</span>
<span class="text-capitalize">{{ $video->orientation }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Dimensions</span>
<span>{{ $video->width ?? 'N/A' }} x {{ $video->height ?? 'N/A' }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Views</span>
<span>{{ number_format(\DB::table('video_views')->where('video_id', $video->id)->count()) }}</span>
</div>
<div class="d-flex justify-content-between">
<span class="text-secondary">Likes</span>
<span>{{ number_format(\DB::table('video_likes')->where('video_id', $video->id)->count()) }}</span>
</div>
<hr style="border-color: var(--border-color);">
<div class="d-grid gap-2">
<a href="{{ route('videos.show', $video->id) }}" target="_blank" class="btn btn-outline-light btn-sm">
<i class="bi bi-play-circle"></i> View Video
</a>
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDelDialog()"
style="border:none;background:none;color:var(--text-2);">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="adm-dialog-body">
<p>You are about to permanently delete <strong>{{ $video->title }}</strong>.</p>
<div class="adm-dialog-warning">
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
The video file, thumbnail, and all associated data will be removed. This cannot be undone.
</div>
</div>
<div class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDelDialog()">Cancel</button>
<form method="POST" action="{{ route('admin.videos.delete', $video) }}">
@csrf @method('DELETE')
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
<i class="bi bi-trash3-fill"></i> Delete Permanently
</button>
</form>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
function openDelDialog() { document.getElementById('delDialog').classList.add('open'); }
function closeDelDialog() { document.getElementById('delDialog').classList.remove('open'); }
document.getElementById('delDialog').addEventListener('click', e => { if (e.target === document.getElementById('delDialog')) closeDelDialog(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDelDialog(); });
</script>
@endsection
File diff suppressed because it is too large. Load diff
+100
View File
@@ -0,0 +1,100 @@
@extends('layouts.app')
@section('title', 'Error Logs | Admin')
@section('content')
<div style="max-width: 1400px; margin: 0 auto; padding: 24px;">
{{-- Header --}}
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; flex-wrap: wrap; gap: 12px;">
<div style="display: flex; align-items: center; gap: 12px;">
<a href="{{ route('admin.dashboard') }}" class="action-btn">
<i class="bi bi-arrow-left"></i> <span>Dashboard</span>
</a>
<h1 style="font-size: 22px; font-weight: 700; margin: 0;">
<i class="bi bi-bug-fill" style="color: #e61e1e; margin-right: 8px;"></i>Error Logs
</h1>
</div>
<div style="display: flex; gap: 8px; align-items: center;">
<span style="font-size: 13px; color: var(--text-secondary);">{{ count($lines) }} entries shown</span>
<a href="{{ route('admin.logs') }}" class="action-btn">
<i class="bi bi-arrow-clockwise"></i> <span>Refresh</span>
</a>
<button class="action-btn" onclick="copyAll()">
<i class="bi bi-clipboard"></i> <span>Copy All</span>
</button>
</div>
</div>
{{-- Filters --}}
<form method="GET" action="{{ route('admin.logs') }}"
style="display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; align-items: flex-end;">
<div style="flex: 1; min-width: 200px;">
<label style="display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">Search</label>
<input type="text" name="filter" value="{{ $filter }}"
placeholder="Filter by keyword…"
style="width: 100%; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 8px 12px; font-size: 13px;">
</div>
<div>
<label style="display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">Level</label>
<select name="level"
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 8px 12px; font-size: 13px; cursor: pointer;">
<option value="" {{ $level === '' ? 'selected' : '' }}>All levels</option>
<option value="ERROR" {{ $level === 'ERROR' ? 'selected' : '' }}>ERROR</option>
<option value="WARNING" {{ $level === 'WARNING' ? 'selected' : '' }}>WARNING</option>
<option value="INFO" {{ $level === 'INFO' ? 'selected' : '' }}>INFO</option>
<option value="DEBUG" {{ $level === 'DEBUG' ? 'selected' : '' }}>DEBUG</option>
</select>
</div>
<div>
<label style="display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">Show</label>
<select name="limit"
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 8px 12px; font-size: 13px; cursor: pointer;">
@foreach([50, 100, 200, 500] as $n)
<option value="{{ $n }}" {{ $limit == $n ? 'selected' : '' }}>{{ $n }} lines</option>
@endforeach
</select>
</div>
<button type="submit" class="action-btn action-btn-primary">
<i class="bi bi-search"></i> <span>Filter</span>
</button>
</form>
{{-- Log lines --}}
@if(empty($lines))
<div style="text-align: center; padding: 60px; color: var(--text-secondary);">
<i class="bi bi-check-circle" style="font-size: 48px; color: #22c55e; display: block; margin-bottom: 12px;"></i>
No log entries found{{ $filter || $level ? ' matching your filter' : '' }}.
</div>
@else
<div id="logOutput" style="font-family: 'Courier New', monospace; font-size: 12px; line-height: 1.6; background: #0a0a0a; border: 1px solid var(--border-color); border-radius: 10px; overflow: auto; max-height: calc(100vh - 260px); padding: 12px 16px;">
@foreach($lines as $line)
@php
$color = '#aaa';
if (str_contains($line, '.ERROR:')) $color = '#f87171';
elseif (str_contains($line, '.WARNING:')) $color = '#fbbf24';
elseif (str_contains($line, '.INFO:')) $color = '#6ee7b7';
elseif (str_contains($line, '.DEBUG:')) $color = '#93c5fd';
@endphp
<div class="log-line" style="color: {{ $color }}; border-bottom: 1px solid #1a1a1a; padding: 3px 0; white-space: pre-wrap; word-break: break-all;">{{ $line }}</div>
@endforeach
</div>
@endif
</div>
<script>
function copyAll() {
const text = Array.from(document.querySelectorAll('.log-line'))
.map(el => el.textContent).join('\n');
navigator.clipboard.writeText(text).then(() => showToast('Copied to clipboard', 'success'));
}
// Auto-scroll to top (newest entries are at top)
document.getElementById('logOutput')?.scrollTo(0, 0);
</script>
@endsection
@@ -0,0 +1,89 @@
@extends('admin.layout')
@section('title', 'NAS Storage')
@section('extra_styles')
<style>
/* Tailwind-like resets needed for the NAS component (light-theme widget) */
.nas-wrapper * { box-sizing: border-box; }
/* brand-* color mappings for the NAS file manager component */
.nas-wrapper .bg-brand-600, .nas-wrapper .bg-brand-700 { background-color: #e61e1e !important; }
.nas-wrapper .text-brand-600, .nas-wrapper .text-brand-500 { color: #e61e1e !important; }
.nas-wrapper .ring-brand-500 { --tw-ring-color: #e61e1e !important; }
.nas-wrapper .hover\:bg-brand-700:hover { background-color: #c91a1a !important; }
.nas-wrapper .focus\:ring-brand-500:focus { --tw-ring-color: #e61e1e !important; }
.nas-wrapper .hover\:text-brand-600:hover { color: #e61e1e !important; }
.nas-wrapper .hover\:bg-brand-50:hover { background-color: rgba(230,30,30,.08) !important; }
.nas-wrapper .bg-brand-600.text-white { color: #fff !important; }
</style>
@endsection
@section('content')
<div class="adm-page-header">
<h1 class="adm-page-title">
<i class="bi bi-hdd-network"></i> NAS Storage
</h1>
</div>
{{-- Connection config summary --}}
<div class="adm-card" style="margin-bottom:20px;">
<div class="adm-card-header">
<span class="adm-card-title"><i class="bi bi-plug"></i> Connection</span>
<a href="{{ route('admin.settings') }}" class="adm-btn adm-btn-sm">
<i class="bi bi-gear"></i> Settings
</a>
</div>
<div class="adm-card-body" style="display:flex;gap:32px;flex-wrap:wrap;">
@php
$conn = config('nas-file-manager.connection');
$fields = [
'Protocol' => strtoupper($conn['protocol'] ?? '—'),
'Host' => $conn['host'] ?: '—',
'Port' => $conn['port'] ?: '—',
'Share' => $conn['smb_share'] ?: '—',
'Path' => $conn['path'] ?: '—',
'User' => $conn['username'] ?: '—',
];
@endphp
@foreach($fields as $label => $value)
<div>
<div style="font-size:11px;color:var(--text-2);text-transform:uppercase;letter-spacing:.5px;margin-bottom:3px;">{{ $label }}</div>
<div style="font-size:13px;font-weight:600;font-family:monospace;">{{ $value }}</div>
</div>
@endforeach
</div>
</div>
{{-- NAS File Manager component (Alpine.js + Tailwind) --}}
<div class="nas-wrapper">
@include('nas-file-manager::file-manager', [
'nodes' => $nodes,
'canEdit' => true,
'title' => 'NAS Storage Browser',
])
</div>
@endsection
@section('scripts')
<script src="https://cdn.jsdelivr.net/npm/@alpinejs/[email protected]/dist/cdn.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/base.min.css">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: {
50: 'rgba(230,30,30,.08)',
500: '#e61e1e',
600: '#e61e1e',
700: '#c91a1a',
}
}
}
},
corePlugins: { preflight: false }
}
</script>
@endsection
@@ -0,0 +1,25 @@
<div class="gpu-grid">
@foreach($gpus as $gpu)
@php
$used = $gpu['mem_total'] - $gpu['mem_free'];
$usedPct = $gpu['mem_total'] > 0 ? round($used / $gpu['mem_total'] * 100) : 0;
$sel = (string)$gpu['index'] === (string)$selectedDevice;
@endphp
<div class="gpu-card {{ $sel ? 'selected' : '' }}"
data-index="{{ $gpu['index'] }}"
onclick="selectGpuCard(this)">
<div class="gpu-card-check">@if($sel)<i class="bi bi-check"></i>@endif</div>
<div class="gpu-card-name">{{ $gpu['name'] }}</div>
<div class="gpu-stat"><span>VRAM</span><span class="gpu-stat-val">{{ number_format($gpu['mem_total']) }} MB</span></div>
<div class="gpu-stat"><span>Free</span><span class="gpu-stat-val">{{ number_format($gpu['mem_free']) }} MB</span></div>
<div class="gpu-stat"><span>GPU Load</span><span class="gpu-stat-val">{{ $gpu['util'] }}%</span></div>
<div class="gpu-stat"><span>Temp</span><span class="gpu-stat-val">{{ $gpu['temp'] }} °C</span></div>
<div class="gpu-stat"><span>Driver</span><span class="gpu-stat-val">{{ $gpu['driver'] }}</span></div>
<div class="mem-bar-wrap">
<div class="mem-bar-track">
<div class="mem-bar-fill" style="width:{{ $usedPct }}%"></div>
</div>
</div>
</div>
@endforeach
</div>
@@ -0,0 +1,47 @@
@if ($paginator->hasPages())
<nav class="adm-pagination" aria-label="Pagination">
{{-- Prev --}}
@if ($paginator->onFirstPage())
<span class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-left"></i></span>
</span>
@else
<a class="page-item" href="{{ $paginator->previousPageUrl() }}" rel="prev">
<span class="page-link"><i class="bi bi-chevron-left"></i></span>
</a>
@endif
{{-- Pages --}}
@foreach ($elements as $element)
@if (is_string($element))
<span class="page-item disabled"><span class="page-link">{{ $element }}</span></span>
@endif
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<span class="page-item active" aria-current="page">
<span class="page-link">{{ $page }}</span>
</span>
@else
<a class="page-item" href="{{ $url }}">
<span class="page-link">{{ $page }}</span>
</a>
@endif
@endforeach
@endif
@endforeach
{{-- Next --}}
@if ($paginator->hasMorePages())
<a class="page-item" href="{{ $paginator->nextPageUrl() }}" rel="next">
<span class="page-link"><i class="bi bi-chevron-right"></i></span>
</a>
@else
<span class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-right"></i></span>
</span>
@endif
</nav>
@endif
+430
View File
@@ -0,0 +1,430 @@
@extends('admin.layout')
@section('title', 'System Settings')
@section('page_title', 'Settings')
@section('extra_styles')
<style>
.settings-section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 24px;
overflow: hidden;
}
.settings-section-header {
display: flex; align-items: center; gap: 10px;
padding: 18px 22px;
border-bottom: 1px solid var(--border);
font-size: 14px; font-weight: 600;
}
.settings-section-header i { color: var(--brand); font-size: 16px; }
.settings-section-body { padding: 22px; }
.setting-row {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 24px; padding: 16px 0;
border-bottom: 1px solid var(--border);
}
.setting-row:last-child { border-bottom: none; padding-bottom: 0; }
.setting-row:first-child { padding-top: 0; }
.setting-label { flex: 1; }
.setting-label strong { display: block; font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 3px; }
.setting-label small { font-size: 12px; color: var(--text-2); line-height: 1.5; }
.setting-control { flex-shrink: 0; min-width: 180px; }
/* Toggle switch */
.toggle-wrap { display: flex; align-items: center; gap: 10px; }
.toggle-switch {
position: relative; width: 44px; height: 24px; flex-shrink: 0; cursor: pointer;
}
.toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.toggle-track {
position: absolute; inset: 0;
background: var(--border-light); border-radius: 12px;
transition: background .2s;
}
.toggle-thumb {
position: absolute; top: 3px; left: 3px;
width: 18px; height: 18px; border-radius: 50%;
background: #fff; transition: transform .2s;
box-shadow: 0 1px 3px rgba(0,0,0,.4);
}
.toggle-switch input:checked ~ .toggle-track { background: var(--brand); }
.toggle-switch input:checked ~ .toggle-thumb { transform: translateX(20px); }
.toggle-label { font-size: 13px; color: var(--text-2); }
.toggle-switch input:checked + .toggle-track + .toggle-label { color: var(--text); }
/* Select */
.adm-select-full {
width: 100%; height: 38px;
background: var(--bg); border: 1px solid var(--border-light);
border-radius: 8px; color: var(--text); font-size: 13px;
padding: 0 12px; outline: none; font-family: inherit;
transition: border-color .15s; cursor: pointer;
}
.adm-select-full:focus { border-color: var(--brand); }
.adm-select-full option { background: #1e1e1e; }
/* GPU cards */
.gpu-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-bottom: 4px; }
.gpu-card {
background: var(--bg); border: 1px solid var(--border);
border-radius: 10px; padding: 16px;
cursor: pointer; transition: border-color .15s, background .15s;
position: relative;
}
.gpu-card:hover { border-color: #444; background: var(--bg-card2); }
.gpu-card.selected { border-color: var(--brand); background: var(--brand-dim); }
.gpu-card-check {
position: absolute; top: 10px; right: 10px;
width: 18px; height: 18px; border-radius: 50%;
border: 2px solid var(--border); background: transparent;
display: flex; align-items: center; justify-content: center;
font-size: 10px; color: #fff;
transition: background .15s, border-color .15s;
}
.gpu-card.selected .gpu-card-check { background: var(--brand); border-color: var(--brand); }
.gpu-card-name { font-size: 13px; font-weight: 600; margin-bottom: 8px; padding-right: 24px; }
.gpu-stat { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.gpu-stat:last-child { margin-bottom: 0; }
.gpu-stat-val { color: var(--text); font-weight: 500; }
/* Mem bar */
.mem-bar-wrap { margin-top: 8px; }
.mem-bar-track { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
.mem-bar-fill { height: 100%; background: var(--brand); border-radius: 2px; transition: width .4s; }
/* No GPU state */
.no-gpu-state { text-align: center; padding: 28px 20px; color: var(--text-2); }
.no-gpu-state i { font-size: 32px; display: block; margin-bottom: 10px; opacity: .3; }
/* Status chip */
.chip {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600;
}
.chip-green { background: rgba(34,197,94,.15); color: #4ade80; border: 1px solid rgba(34,197,94,.25); }
.chip-red { background: rgba(239,68,68,.15); color: #f87171; border: 1px solid rgba(239,68,68,.25); }
.chip-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
/* Encoder option cards */
.enc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
.enc-card {
background: var(--bg); border: 1px solid var(--border);
border-radius: 8px; padding: 12px 14px;
cursor: pointer; transition: border-color .15s;
text-align: left;
}
.enc-card:hover { border-color: #444; }
.enc-card.selected { border-color: var(--brand); background: var(--brand-dim); }
.enc-card-name { font-size: 13px; font-weight: 600; display: block; }
.enc-card-desc { font-size: 11px; color: var(--text-2); margin-top: 3px; display: block; }
.save-bar {
display: flex; align-items: center; justify-content: flex-end;
gap: 12px; padding-top: 4px;
}
</style>
@endsection
@section('content')
<div class="adm-page-header">
<h1 class="adm-page-title"><i class="bi bi-gear-fill"></i> System Settings</h1>
</div>
@if(session('success'))
<div class="adm-alert adm-alert-success" style="margin-bottom:20px;">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
</div>
@endif
@if($errors->any())
<div class="adm-alert adm-alert-error" style="margin-bottom:20px;">
<i class="bi bi-exclamation-triangle-fill"></i>
<span>{{ $errors->first() }}</span>
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
</div>
@endif
<form method="POST" action="{{ route('admin.settings.update') }}" id="settingsForm">
@csrf
{{-- ── GPU Processing ───────────────────────────────────────── --}}
<div class="settings-section">
<div class="settings-section-header">
<i class="bi bi-gpu-card"></i>
GPU Accelerated Processing
<span id="gpuStatusChip" style="margin-left:6px;">
@if(count($gpus))
<span class="chip chip-green"><span class="chip-dot"></span> {{ count($gpus) }} GPU{{ count($gpus) > 1 ? 's' : '' }} detected</span>
@else
<span class="chip chip-red"><span class="chip-dot"></span> No GPU detected</span>
@endif
</span>
{{-- NVENC encoding health --}}
<span id="nvencStatusChip" style="margin-left:6px;">
@if($nvencWorks)
<span class="chip chip-green"><span class="chip-dot"></span> NVENC encoding ✓</span>
@else
<span class="chip chip-red"><span class="chip-dot"></span> NVENC encoding ✗</span>
@endif
</span>
</div>
@if(!$nvencWorks && count($gpus))
<div style="background:rgba(239,68,68,.08);border-left:3px solid #f87171;padding:12px 18px;font-size:13px;color:#f87171;line-height:1.6;">
<strong>⚠ NVENC is not working with the current FFmpeg binary.</strong><br>
The GPU is detected but FFmpeg {{ shell_exec('/usr/bin/ffmpeg -version 2>/dev/null | head -1') ?? '' }} cannot initialise CUDA on this driver.<br>
<strong>Fix:</strong> Install a newer FFmpeg with CUDA 12+ support (e.g. <code>jellyfin-ffmpeg7</code>), then update the binary path below.<br>
Until then, video encoding will automatically fall back to CPU (libx264).
</div>
@endif
<div class="settings-section-body">
{{-- Detect button + GPU cards --}}
<div style="margin-bottom:20px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
<span style="font-size:13px;color:var(--text-2);">Available GPUs</span>
<button type="button" class="adm-btn adm-btn-sm" id="detectBtn" onclick="detectGpus()">
<i class="bi bi-arrow-repeat" id="detectIcon"></i> Detect GPUs
</button>
</div>
<div id="gpuCardsWrap">
@if(count($gpus))
@include('admin.partials.gpu-cards', ['gpus' => $gpus, 'selectedDevice' => $settings['gpu_device']])
@else
<div class="no-gpu-state">
<i class="bi bi-gpu-card"></i>
<p>No NVIDIA GPUs detected. Click "Detect GPUs" to scan.</p>
</div>
@endif
</div>
</div>
{{-- Enable GPU --}}
<div class="setting-row">
<div class="setting-label">
<strong>Enable GPU acceleration</strong>
<small>When enabled, video encoding uses the NVIDIA GPU. When disabled, falls back to CPU (libx264).</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<div class="toggle-switch">
<input type="checkbox" id="gpuEnabledInput" name="gpu_enabled_check"
{{ $settings['gpu_enabled'] === 'true' ? 'checked' : '' }}>
<div class="toggle-track"></div>
<div class="toggle-thumb"></div>
</div>
<span class="toggle-label" id="gpuEnabledLabel">
{{ $settings['gpu_enabled'] === 'true' ? 'Enabled' : 'Disabled' }}
</span>
</label>
<input type="hidden" name="gpu_enabled" id="gpuEnabledHidden"
value="{{ $settings['gpu_enabled'] }}">
</div>
</div>
{{-- GPU device (hidden input, controlled by card click) --}}
<input type="hidden" name="gpu_device" id="gpuDeviceInput" value="{{ $settings['gpu_device'] }}">
{{-- GPU Encoder --}}
<div class="setting-row" id="gpuEncoderRow">
<div class="setting-label">
<strong>Video encoder</strong>
<small>h264_nvenc is broadly compatible. hevc_nvenc produces smaller files (H.265) but requires compatible players. libx264 forces CPU encoding regardless of the toggle above.</small>
</div>
<div class="setting-control">
<div class="enc-grid">
@foreach([
['h264_nvenc', 'H.264 NVENC', 'GPU · max compatibility'],
['hevc_nvenc', 'H.265 NVENC', 'GPU · smaller files'],
['libx264', 'libx264', 'CPU · software fallback'],
] as [$val, $label, $desc])
<button type="button"
class="enc-card {{ $settings['gpu_encoder'] === $val ? 'selected' : '' }}"
data-encoder="{{ $val }}"
onclick="selectEncoder(this)">
<span class="enc-card-name">{{ $label }}</span>
<span class="enc-card-desc">{{ $desc }}</span>
</button>
@endforeach
</div>
<input type="hidden" name="gpu_encoder" id="gpuEncoderInput" value="{{ $settings['gpu_encoder'] }}">
</div>
</div>
{{-- Encoding preset --}}
<div class="setting-row" id="gpuPresetRow">
<div class="setting-label">
<strong>Encoding preset</strong>
<small>NVENC presets: p1 (fastest) → p7 (best quality). libx264 presets: fast / medium / slow. Preset only affects speed vs file size; quality is controlled by CQ/CRF.</small>
</div>
<div class="setting-control">
<select name="gpu_preset" class="adm-select-full" id="gpuPresetSelect">
<optgroup label="NVENC (GPU)">
@foreach(['p1','p2','p3','p4','p5','p6','p7'] as $p)
<option value="{{ $p }}" {{ $settings['gpu_preset'] === $p ? 'selected' : '' }}>
{{ $p }}{{ $p === 'p4' ? ' — recommended' : '' }}
</option>
@endforeach
</optgroup>
<optgroup label="libx264 (CPU)">
@foreach(['fast','medium','slow'] as $p)
<option value="{{ $p }}" {{ $settings['gpu_preset'] === $p ? 'selected' : '' }}>
{{ $p }}
</option>
@endforeach
</optgroup>
</select>
</div>
</div>
{{-- HW Accel --}}
<div class="setting-row" id="gpuHwaccelRow">
<div class="setting-label">
<strong>Hardware decode acceleration</strong>
<small>Use CUDA to decode the source video on the GPU before re-encoding, speeding up the pipeline. Disable if you see CUDA errors in the logs.</small>
</div>
<div class="setting-control">
<select name="gpu_hwaccel" class="adm-select-full">
<option value="cuda" {{ $settings['gpu_hwaccel'] === 'cuda' ? 'selected' : '' }}>cuda — GPU decode</option>
<option value="none" {{ $settings['gpu_hwaccel'] === 'none' ? 'selected' : '' }}>none — CPU decode</option>
</select>
</div>
</div>
{{-- FFmpeg binary path --}}
<div class="setting-row">
<div class="setting-label">
<strong>FFmpeg binary path</strong>
<small>
Absolute path to the <code>ffmpeg</code> executable.
Change this to use a newer build (e.g. <code>/usr/lib/jellyfin-ffmpeg/ffmpeg</code>)
that supports your GPU driver. Current: <code>{{ config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg') }}</code>
</small>
</div>
<div class="setting-control">
<input type="text" name="ffmpeg_binary" class="adm-select-full" style="height:auto;padding:8px 12px;"
value="{{ $settings['ffmpeg_binary'] }}"
placeholder="/usr/bin/ffmpeg">
</div>
</div>
</div>
</div>
{{-- ── Save ─────────────────────────────────────────────────── --}}
<div class="save-bar">
<a href="{{ route('admin.dashboard') }}" class="adm-btn">Cancel</a>
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-floppy"></i> Save Settings
</button>
</div>
</form>
@endsection
@section('scripts')
<script>
// ── GPU card selection ────────────────────────────────────────
function selectGpuCard(el) {
document.querySelectorAll('.gpu-card').forEach(c => c.classList.remove('selected'));
el.classList.add('selected');
document.getElementById('gpuDeviceInput').value = el.dataset.index;
}
// ── Encoder card selection ────────────────────────────────────
function selectEncoder(el) {
document.querySelectorAll('.enc-card').forEach(c => c.classList.remove('selected'));
el.classList.add('selected');
document.getElementById('gpuEncoderInput').value = el.dataset.encoder;
// Sync preset optgroup visibility hint
const isCpu = el.dataset.encoder === 'libx264';
document.getElementById('gpuPresetSelect').value = isCpu ? 'fast' : 'p4';
}
// ── GPU toggle ────────────────────────────────────────────────
const gpuToggle = document.getElementById('gpuEnabledInput');
const gpuHidden = document.getElementById('gpuEnabledHidden');
const gpuLabel = document.getElementById('gpuEnabledLabel');
function applyGpuToggle() {
const on = gpuToggle.checked;
gpuHidden.value = on ? 'true' : 'false';
gpuLabel.textContent = on ? 'Enabled' : 'Disabled';
}
gpuToggle.addEventListener('change', applyGpuToggle);
// ── Live GPU detect ───────────────────────────────────────────
async function detectGpus() {
const btn = document.getElementById('detectBtn');
const icon = document.getElementById('detectIcon');
btn.disabled = true;
icon.className = 'bi bi-arrow-repeat spin';
try {
const res = await fetch('{{ route('admin.settings.detect-gpu') }}');
const data = await res.json();
const gpus = data.gpus || [];
const wrap = document.getElementById('gpuCardsWrap');
const chip = document.getElementById('gpuStatusChip');
const selectedDevice = parseInt(document.getElementById('gpuDeviceInput').value);
if (gpus.length === 0) {
wrap.innerHTML = '<div class="no-gpu-state"><i class="bi bi-gpu-card"></i><p>No NVIDIA GPUs detected.</p></div>';
chip.innerHTML = '<span class="chip chip-red"><span class="chip-dot"></span> No GPU detected</span>';
} else {
chip.innerHTML = `<span class="chip chip-green"><span class="chip-dot"></span> ${gpus.length} GPU${gpus.length > 1 ? 's' : ''} detected</span>`;
wrap.innerHTML = buildGpuCards(gpus, selectedDevice);
}
} catch (e) {
console.error(e);
}
btn.disabled = false;
icon.className = 'bi bi-arrow-repeat';
}
function buildGpuCards(gpus, selectedDevice) {
if (!gpus.length) return '';
const grid = document.createElement('div');
grid.className = 'gpu-grid';
gpus.forEach(gpu => {
const used = gpu.mem_total - gpu.mem_free;
const usedPct = Math.round((used / gpu.mem_total) * 100);
const sel = gpu.index === selectedDevice;
const card = document.createElement('div');
card.className = 'gpu-card' + (sel ? ' selected' : '');
card.dataset.index = gpu.index;
card.onclick = function() { selectGpuCard(this); };
card.innerHTML = `
<div class="gpu-card-check">${sel ? '<i class="bi bi-check"></i>' : ''}</div>
<div class="gpu-card-name">${escHtml(gpu.name)}</div>
<div class="gpu-stat"><span>VRAM</span><span class="gpu-stat-val">${gpu.mem_total.toLocaleString()} MB</span></div>
<div class="gpu-stat"><span>Free</span><span class="gpu-stat-val">${gpu.mem_free.toLocaleString()} MB</span></div>
<div class="gpu-stat"><span>GPU Load</span><span class="gpu-stat-val">${gpu.util}%</span></div>
<div class="gpu-stat"><span>Temp</span><span class="gpu-stat-val">${gpu.temp} °C</span></div>
<div class="gpu-stat"><span>Driver</span><span class="gpu-stat-val">${escHtml(gpu.driver)}</span></div>
<div class="mem-bar-wrap">
<div class="mem-bar-track"><div class="mem-bar-fill" style="width:${usedPct}%"></div></div>
</div>`;
grid.appendChild(card);
});
return grid.outerHTML;
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
<style>
@keyframes spin { to { transform: rotate(360deg); } }
.spin { display: inline-block; animation: spin .6s linear infinite; }
</style>
@endsection
+170 -118
View File
@@ -1,135 +1,181 @@
@extends('admin.layout')
@section('title', 'User Management')
@section('page_title', 'User Management')
@section('title', 'Users')
@section('content')
<!-- Alerts -->
{{-- ── Page header ──────────────────────────────────────────────────── --}}
<div class="adm-page-header">
<h1 class="adm-page-title"><i class="bi bi-people"></i> Users</h1>
</div>
{{-- ── Alerts ───────────────────────────────────────────────────────── --}}
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="adm-alert adm-alert-success">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
@if(session('error'))
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ session('error') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="adm-alert adm-alert-error">
<i class="bi bi-exclamation-circle-fill"></i>
<span>{{ session('error') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
<!-- Search & Filters -->
<div class="admin-card">
<form method="GET" action="{{ route('admin.users') }}" class="filter-form">
<div class="form-group">
<label for="search">Search</label>
<input type="text" name="search" id="search" class="form-control" placeholder="Search by name or email..." value="{{ request('search') }}">
</div>
<div class="form-group">
<label for="role">Role</label>
<select name="role" id="role" class="form-select">
{{-- ── Filter card ──────────────────────────────────────────────────── --}}
<div class="adm-card">
<div class="adm-card-body" style="padding:16px 20px;">
<form method="GET" action="{{ route('admin.users') }}" class="adm-filter-form">
<div class="adm-filter-search">
<i class="bi bi-search"></i>
<input type="text" name="search" class="adm-input"
placeholder="Search name or email…"
value="{{ request('search') }}" autocomplete="off">
</div>
<select name="role" class="adm-select">
<option value="">All Roles</option>
<option value="user" {{ request('role') == 'user' ? 'selected' : '' }}>User</option>
<option value="admin" {{ request('role') == 'admin' ? 'selected' : '' }}>Admin</option>
<option value="super_admin" {{ request('role') == 'super_admin' ? 'selected' : '' }}>Super Admin</option>
<option value="user" {{ request('role') === 'user' ? 'selected' : '' }}>User</option>
<option value="admin" {{ request('role') === 'admin' ? 'selected' : '' }}>Admin</option>
<option value="super_admin" {{ request('role') === 'super_admin' ? 'selected' : '' }}>Super Admin</option>
</select>
</div>
<div class="form-group">
<label for="sort">Sort By</label>
<select name="sort" id="sort" class="form-select">
<option value="latest" {{ request('sort') == 'latest' ? 'selected' : '' }}>Latest First</option>
<option value="oldest" {{ request('sort') == 'oldest' ? 'selected' : '' }}>Oldest First</option>
<option value="name_asc" {{ request('sort') == 'name_asc' ? 'selected' : '' }}>Name (A-Z)</option>
<option value="name_desc" {{ request('sort') == 'name_desc' ? 'selected' : '' }}>Name (Z-A)</option>
<select name="sort" class="adm-select">
<option value="latest" {{ request('sort', 'latest') === 'latest' ? 'selected' : '' }}>Newest first</option>
<option value="oldest" {{ request('sort') === 'oldest' ? 'selected' : '' }}>Oldest first</option>
<option value="name_asc" {{ request('sort') === 'name_asc' ? 'selected' : '' }}>Name A–Z</option>
<option value="name_desc" {{ request('sort') === 'name_desc' ? 'selected' : '' }}>Name Z–A</option>
</select>
</div>
<div class="form-group">
<label>&nbsp;</label>
<button type="submit" class="btn btn-primary">
<i class="bi bi-search"></i> Filter
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-funnel"></i> Filter
</button>
<a href="{{ route('admin.users') }}" class="btn btn-outline-light">
<i class="bi bi-x-circle"></i> Clear
@if(request()->hasAny(['search','role','sort']))
<a href="{{ route('admin.users') }}" class="adm-btn">
<i class="bi bi-x-lg"></i> Clear
</a>
</div>
</form>
@endif
</form>
</div>
</div>
<!-- Users Table -->
<div class="admin-card">
<div class="admin-card-header">
All Users ({{ $users->count() }})
{{-- ── Users table ──────────────────────────────────────────────────── --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title">
<i class="bi bi-people"></i>
All Users
<span class="adm-badge adm-badge-user">{{ $users->total() ?? $users->count() }}</span>
</div>
</div>
<div class="table-responsive">
<table class="admin-table">
<div class="adm-table-wrap">
<table class="adm-table">
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Role</th>
<th>Verified</th>
<th>Videos</th>
<th>Joined</th>
<th>Actions</th>
<th style="width:80px; text-align:right;">Actions</th>
</tr>
</thead>
<tbody>
@forelse($users as $user)
<tr>
{{-- User cell --}}
<td>
<div class="d-flex align-items-center gap-2">
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="user-avatar">
<div class="adm-user-cell">
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}">
<div>
<div>{{ $user->name }}</div>
@if($user->id === auth()->id())
<small class="text-info">(You)</small>
@endif
<div style="display:flex;align-items:center;gap:4px;">
<span class="adm-user-cell-name">{{ $user->name }}</span>
@if($user->id === auth()->id())
<span class="adm-user-cell-you">you</span>
@endif
</div>
<div class="adm-user-cell-email">{{ $user->email }}</div>
</div>
</div>
</td>
<td>{{ $user->email }}</td>
{{-- Role --}}
<td>
@if($user->role === 'super_admin')
<span class="badge-role badge-super-admin">Super Admin</span>
<span class="adm-badge adm-badge-superadmin"><i class="bi bi-shield-fill"></i> Super Admin</span>
@elseif($user->role === 'admin')
<span class="badge-role badge-admin">Admin</span>
<span class="adm-badge adm-badge-admin"><i class="bi bi-person-badge"></i> Admin</span>
@else
<span class="badge-role badge-user">User</span>
<span class="adm-badge adm-badge-user"><i class="bi bi-person"></i> User</span>
@endif
</td>
{{-- Verified --}}
<td>
<a href="{{ route('channel', $user->id) }}" target="_blank" class="text-decoration-none">
{{ $user->videos->count() }} videos
@if($user->email_verified_at)
<span class="adm-badge adm-badge-verified"><i class="bi bi-check-circle-fill"></i> Verified</span>
@else
<span class="adm-badge adm-badge-unverified"><i class="bi bi-clock"></i> Pending</span>
@endif
</td>
{{-- Videos --}}
<td>
<a href="{{ route('channel', $user->channel) }}" target="_blank"
class="text-dim" style="text-decoration:none; font-size:13px;">
{{ $user->videos->count() }}
<i class="bi bi-box-arrow-up-right" style="font-size:10px; opacity:.5; margin-left:2px;"></i>
</a>
</td>
<td>{{ $user->created_at->format('M d, Y') }}</td>
{{-- Joined --}}
<td class="text-muted-sm">{{ $user->created_at->format('M d, Y') }}</td>
{{-- Actions --}}
<td>
<div class="dropdown">
<button class="btn btn-sm btn-outline-light dropdown-toggle" data-bs-toggle="dropdown">
<i class="bi bi-gear"></i>
<div class="adm-row-actions" style="justify-content:flex-end;">
@if(!$user->email_verified_at)
<form method="POST" action="{{ route('admin.users.verify', $user->id) }}" style="display:inline;">
@csrf
<button type="submit" class="adm-btn adm-btn-sm adm-btn-verify" title="Manually verify account">
<i class="bi bi-patch-check-fill"></i>
</button>
</form>
@endif
@if($user->id !== auth()->id() && !$user->isSuperAdmin())
<form method="POST" action="{{ route('admin.users.impersonate', $user->id) }}" style="display:inline;">
@csrf
<button type="submit" class="adm-btn adm-btn-sm adm-btn-impersonate" title="Impersonate user">
<i class="bi bi-person-fill-gear"></i>
</button>
</form>
@endif
<a href="{{ route('admin.users.edit', $user->id) }}"
class="adm-btn adm-btn-sm" title="Edit user">
<i class="bi bi-pencil"></i>
</a>
@if($user->id !== auth()->id())
<button type="button"
class="adm-btn adm-btn-sm adm-btn-danger"
title="Delete user"
onclick="openDeleteDialog({{ $user->id }}, '{{ addslashes($user->name) }}')">
<i class="bi bi-trash"></i>
</button>
<ul class="dropdown-menu dropdown-menu-dark">
<li>
<a class="dropdown-item" href="{{ route('admin.users.edit', $user->id) }}">
<i class="bi bi-pencil"></i> Edit
</a>
</li>
@if($user->id !== auth()->id())
<li>
<button class="dropdown-item text-danger" onclick="confirmDeleteUser({{ $user->id }}, '{{ $user->name }}')">
<i class="bi bi-trash"></i> Delete
</button>
</li>
@endif
</ul>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="text-center text-secondary py-4">
No users found
<td colspan="6">
<div class="empty-state">
<i class="bi bi-people"></i>
<p>No users found</p>
</div>
</td>
</tr>
@endforelse
@@ -137,40 +183,42 @@ All Users ({{ $users->count() }})
</table>
</div>
<!-- Pagination -->
<div class="d-flex justify-content-center">
{{ $users->links() }}
{{-- Pagination --}}
@if($users instanceof \Illuminate\Pagination\LengthAwarePaginator && $users->hasPages())
<div style="padding:16px 20px; border-top:1px solid var(--border);">
{{ $users->onEachSide(1)->links() }}
</div>
@endif
</div>
<!-- Delete User Modal -->
<div class="modal fade" id="deleteUserModal" tabindex="-1" aria-labelledby="deleteUserModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content" style="background: #1a1a1a; border: 1px solid #3f3f3f; border-radius: 12px;">
<div class="modal-header" style="border-bottom: 1px solid #3f3f3f; padding: 20px 24px;">
<h5 class="modal-title" id="deleteUserModalLabel" style="color: #fff; font-weight: 600;">
<i class="bi bi-exclamation-triangle-fill text-danger me-2"></i>
Delete User
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
{{-- ── Delete confirmation dialog ───────────────────────────────────── --}}
<div class="adm-dialog-overlay" id="deleteDialog">
<div class="adm-dialog">
<div class="adm-dialog-header">
<div class="adm-dialog-title">
<i class="bi bi-exclamation-triangle-fill"></i>
Delete User
</div>
<div class="modal-body" style="padding: 24px;">
<p>Are you sure you want to delete this user? This action cannot be undone.</p>
<p><strong>User:</strong> <span id="deleteUserName"></span></p>
<div class="alert alert-warning">
<i class="bi bi-info-circle me-2"></i>
This will also delete all videos uploaded by this user.
</div>
</div>
<div class="modal-footer" style="border-top: 1px solid #3f3f3f; padding: 16px 24px;">
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>
<form id="deleteUserForm" method="POST">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger">Delete User</button>
</form>
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDeleteDialog()" style="border:none;background:none;color:var(--text-2);">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="adm-dialog-body">
<p>You are about to permanently delete <strong id="dlgUserName"></strong>.</p>
<div class="adm-dialog-warning">
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
All videos uploaded by this user will also be deleted. This cannot be undone.
</div>
</div>
<div class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDeleteDialog()">Cancel</button>
<form id="deleteForm" method="POST">
@csrf @method('DELETE')
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
<i class="bi bi-trash"></i> Delete User
</button>
</form>
</div>
</div>
</div>
@@ -178,15 +226,19 @@ All Users ({{ $users->count() }})
@section('scripts')
<script>
let currentDeleteUserId = null;
function confirmDeleteUser(userId, userName) {
currentDeleteUserId = userId;
document.getElementById('deleteUserName').textContent = userName;
document.getElementById('deleteUserForm').action = '/admin/users/' + userId;
const modal = new bootstrap.Modal(document.getElementById('deleteUserModal'));
modal.show();
}
function openDeleteDialog(userId, userName) {
document.getElementById('dlgUserName').textContent = userName;
document.getElementById('deleteForm').action = '/admin/users/' + userId;
document.getElementById('deleteDialog').classList.add('open');
}
function closeDeleteDialog() {
document.getElementById('deleteDialog').classList.remove('open');
}
document.getElementById('deleteDialog').addEventListener('click', function(e) {
if (e.target === this) closeDeleteDialog();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeDeleteDialog();
});
</script>
@endsection
@@ -0,0 +1,590 @@
@extends('admin.layout')
@section('title', 'Analytics — ' . $video->title)
@section('page_title', 'Video Analytics')
@php
function flagEmoji(string $code): string {
$chars = '';
foreach (str_split(strtoupper($code)) as $c) {
$chars .= mb_chr(0x1F1E6 + ord($c) - ord('A'));
}
return $chars;
}
@endphp
@section('content')
<style>
.analytics-back {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
text-decoration: none;
font-size: 13px;
margin-bottom: 18px;
transition: color .15s;
}
.analytics-back:hover { color: var(--text-primary); }
/* Video info header */
.video-info-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px 24px;
display: flex;
gap: 20px;
align-items: flex-start;
margin-bottom: 24px;
flex-wrap: wrap;
}
.video-info-thumb {
width: 140px;
height: 88px;
border-radius: 8px;
object-fit: cover;
background: #1a1a1a;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 28px;
}
.video-info-thumb img { width: 100%; height: 100%; object-fit: cover; border-radius: 8px; }
.video-info-body { flex: 1; min-width: 200px; }
.video-info-title { font-size: 18px; font-weight: 700; margin-bottom: 6px; line-height: 1.3; }
.video-info-meta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; }
.video-info-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
padding: 3px 10px;
border-radius: 20px;
background: #2a2a2a;
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.video-info-badge.red { color: var(--brand-red); border-color: var(--brand-red); }
.video-info-actions { display: flex; gap: 10px; align-items: center; margin-left: auto; flex-shrink: 0; }
/* KPI cards */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 14px;
margin-bottom: 24px;
}
.kpi-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 18px 16px;
text-align: center;
}
.kpi-card .kpi-icon {
font-size: 22px;
margin-bottom: 8px;
color: var(--text-secondary);
}
.kpi-card .kpi-value {
font-size: 26px;
font-weight: 700;
line-height: 1;
margin-bottom: 4px;
}
.kpi-card .kpi-label {
font-size: 12px;
color: var(--text-secondary);
}
.kpi-card.red .kpi-value { color: var(--brand-red); }
.kpi-card.green .kpi-value { color: #4caf50; }
.kpi-card.blue .kpi-value { color: #2196f3; }
.kpi-card.orange .kpi-value { color: #ff9800; }
.kpi-card.purple .kpi-value { color: #9c27b0; }
.kpi-card.teal .kpi-value { color: #009688; }
/* Panel */
.an-panel {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px 22px;
margin-bottom: 24px;
}
.an-panel-title {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: .6px;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 7px;
}
.chart-wrap { position: relative; height: 220px; }
.chart-wrap-tall { position: relative; height: 280px; }
/* Country list */
.country-row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
border-bottom: 1px solid rgba(255,255,255,.04);
}
.country-row:last-child { border-bottom: none; }
.country-rank { font-size: 12px; color: var(--text-secondary); width: 18px; text-align: right; flex-shrink: 0; }
.country-flag { font-size: 18px; line-height: 1; flex-shrink: 0; }
.country-name { flex: 1; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.country-bar-wrap { width: 90px; flex-shrink: 0; }
.country-bar { height: 5px; border-radius: 3px; background: var(--brand-red); min-width: 3px; }
.country-count { font-size: 13px; font-weight: 600; flex-shrink: 0; width: 36px; text-align: right; }
/* Age & gender grid */
.analytics-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
@media (max-width: 768px) { .analytics-2col { grid-template-columns: 1fr; } }
/* Gender placeholder */
.gender-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 160px;
color: var(--text-secondary);
text-align: center;
gap: 10px;
}
.gender-placeholder i { font-size: 32px; opacity: .4; }
.gender-placeholder p { font-size: 13px; line-height: 1.5; max-width: 260px; margin: 0; }
/* Recent viewers table */
.viewer-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.viewer-table th {
font-size: 11px;
text-transform: uppercase;
letter-spacing: .5px;
color: var(--text-secondary);
padding: 0 12px 10px;
text-align: left;
border-bottom: 1px solid var(--border-color);
font-weight: 500;
}
.viewer-table td { padding: 10px 12px; border-bottom: 1px solid rgba(255,255,255,.04); vertical-align: middle; }
.viewer-table tr:last-child td { border-bottom: none; }
.viewer-avatar {
width: 30px; height: 30px; border-radius: 50%; object-fit: cover;
background: #2a2a2a; display: inline-flex; align-items: center;
justify-content: center; font-size: 13px; color: var(--text-secondary);
}
.viewer-avatar img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
.guest-badge { font-size: 10px; padding: 2px 7px; border-radius: 10px; background: #252525; color: #888; border: 1px solid #333; }
.auth-badge { font-size: 10px; padding: 2px 7px; border-radius: 10px; background: rgba(33,150,243,.12); color: #64b5f6; border: 1px solid rgba(33,150,243,.25); }
</style>
<!-- Back -->
<a href="{{ route('admin.videos') }}" class="analytics-back">
<i class="bi bi-arrow-left"></i> Back to Videos
</a>
<!-- Video info header -->
<div class="video-info-card">
<div class="video-info-thumb">
@if($video->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}">
@else
<i class="bi bi-play-circle"></i>
@endif
</div>
<div class="video-info-body">
<div class="video-info-title">{{ $video->title }}</div>
<div style="font-size:13px; color:var(--text-secondary); margin-bottom:6px;">
by <a href="{{ route('channel', $video->user->channel) }}" target="_blank" style="color:var(--text-secondary);">{{ $video->user->name }}</a>
</div>
<div class="video-info-meta">
<span class="video-info-badge">
<i class="bi bi-calendar3"></i>
{{ $video->created_at->format('M d, Y') }}
</span>
<span class="video-info-badge">
<i class="bi bi-clock"></i>
{{ $video->formatted_duration ?? '—' }}
</span>
<span class="video-info-badge">
<i class="bi bi-tag"></i>
{{ ucfirst($video->type) }}
</span>
@php
$statusColors = ['ready'=>'#4caf50','processing'=>'#ff9800','pending'=>'#888','failed'=>'#f44336'];
$sc = $statusColors[$video->status] ?? '#888';
@endphp
<span class="video-info-badge" style="color:{{ $sc }}; border-color:{{ $sc }};">
<i class="bi bi-circle-fill" style="font-size:7px;"></i>
{{ ucfirst($video->status) }}
</span>
<span class="video-info-badge">
<i class="bi bi-eye"></i>
{{ ucfirst($video->visibility) }}
</span>
</div>
</div>
<div class="video-info-actions">
<a href="{{ route('videos.show', $video) }}" target="_blank" class="btn btn-sm btn-outline-light">
<i class="bi bi-play-circle"></i> Watch
</a>
<a href="{{ route('admin.videos.edit', $video) }}" class="btn btn-sm btn-outline-light">
<i class="bi bi-pencil"></i> Edit
</a>
</div>
</div>
<!-- KPIs -->
<div class="kpi-grid">
<div class="kpi-card red">
<div class="kpi-icon"><i class="bi bi-eye"></i></div>
<div class="kpi-value">{{ number_format($totalViews) }}</div>
<div class="kpi-label">Total Views</div>
<div style="font-size:10px; color:var(--text-secondary); margin-top:3px;">all watch events</div>
</div>
<div class="kpi-card blue">
<div class="kpi-icon"><i class="bi bi-people"></i></div>
<div class="kpi-value">{{ number_format($totalUniqueViewers) }}</div>
<div class="kpi-label">Unique Viewers</div>
<div style="font-size:10px; color:var(--text-secondary); margin-top:3px;">each person once</div>
</div>
<div class="kpi-card green">
<div class="kpi-icon"><i class="bi bi-heart"></i></div>
<div class="kpi-value">{{ number_format($totalLikes) }}</div>
<div class="kpi-label">Likes</div>
</div>
<div class="kpi-card orange">
<div class="kpi-icon"><i class="bi bi-chat"></i></div>
<div class="kpi-value">{{ number_format($totalComments) }}</div>
<div class="kpi-label">Comments</div>
</div>
<div class="kpi-card purple">
<div class="kpi-icon"><i class="bi bi-person-check"></i></div>
<div class="kpi-value">{{ number_format($authViewers) }}</div>
<div class="kpi-label">Logged-in</div>
</div>
<div class="kpi-card teal">
<div class="kpi-icon"><i class="bi bi-incognito"></i></div>
<div class="kpi-value">{{ number_format($guestViewers) }}</div>
<div class="kpi-label">Guests</div>
</div>
</div>
<!-- Views over time -->
<div class="an-panel">
<div class="an-panel-title"><i class="bi bi-graph-up"></i> View Events – Last 30 Days <span style="font-weight:400; font-size:11px; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(total plays, not unique)</span></div>
<div class="chart-wrap">
<canvas id="dailyChart"></canvas>
</div>
</div>
<!-- Countries -->
<div class="analytics-2col">
<!-- Left: list -->
<div class="an-panel" style="margin-bottom:0;">
<div class="an-panel-title"><i class="bi bi-globe2"></i> Viewers by Country <span style="font-weight:400; font-size:11px; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(unique viewers)</span></div>
@php $maxCountry = $viewsByCountry->first()->total ?? 1; @endphp
@forelse($viewsByCountry as $i => $row)
<div class="country-row">
<span class="country-rank">{{ $i + 1 }}</span>
<span class="country-flag">{{ flagEmoji($row->country) }}</span>
<span class="country-name">{{ $row->country_name ?? $row->country }}</span>
<div class="country-bar-wrap">
<div class="country-bar" style="width:{{ round(($row->total / $maxCountry) * 100) }}%"></div>
</div>
<span class="country-count">{{ number_format($row->total) }}</span>
</div>
@empty
<div style="text-align:center; padding:40px 0; color:var(--text-secondary); font-size:13px;">
No country data yet — views are still being collected.
</div>
@endforelse
</div>
<!-- Right: chart -->
<div class="an-panel" style="margin-bottom:0;">
<div class="an-panel-title"><i class="bi bi-bar-chart-horizontal"></i> Country Distribution</div>
@if($viewsByCountry->isNotEmpty())
<div class="chart-wrap-tall">
<canvas id="countryChart"></canvas>
</div>
@else
<div style="text-align:center; padding:40px 0; color:var(--text-secondary); font-size:13px;">
Chart will appear once geo data is collected.
</div>
@endif
</div>
</div>
<div style="margin-bottom:24px;"></div>
<!-- Age groups + Gender -->
<div class="analytics-2col">
<!-- Age -->
<div class="an-panel" style="margin-bottom:0;">
<div class="an-panel-title"><i class="bi bi-person-badge"></i> Age Groups <span style="font-weight:400; font-size:11px; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(unique viewers)</span></div>
@php $totalAge = array_sum($ageGroups); @endphp
@if($totalAge > 0)
<div class="chart-wrap">
<canvas id="ageChart"></canvas>
</div>
<div style="margin-top:14px; display:grid; grid-template-columns: repeat(auto-fill, minmax(100px,1fr)); gap:6px;">
@foreach($ageGroups as $label => $count)
@if($count > 0)
<div style="background:#1a1a1a; border-radius:8px; padding:8px 10px; text-align:center;">
<div style="font-size:15px; font-weight:700;">{{ number_format($count) }}</div>
<div style="font-size:11px; color:var(--text-secondary);">{{ $label }}</div>
</div>
@endif
@endforeach
</div>
@else
<div style="text-align:center; padding:40px 0; color:var(--text-secondary); font-size:13px;">
Age data requires users to set their birthday in profile settings.
</div>
@endif
</div>
<!-- Gender -->
<div class="an-panel" style="margin-bottom:0;">
<div class="an-panel-title"><i class="bi bi-gender-ambiguous"></i> Gender Split <span style="font-weight:400; font-size:11px; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(unique viewers)</span></div>
@php $genderTotal = array_sum($genderCounts); @endphp
@if($genderTotal > 0)
<div style="display:flex; align-items:center; gap:24px; flex-wrap:wrap;">
<div style="position:relative; width:160px; height:160px; flex-shrink:0; margin: 0 auto;">
<canvas id="genderChart"></canvas>
</div>
<div style="flex:1; min-width:120px;">
@php
$genderIcons = ['Male' => 'bi-gender-male', 'Female' => 'bi-gender-female', 'Prefer not to say' => 'bi-dash-circle'];
$genderColors = ['Male' => '#2196f3', 'Female' => '#e91e63', 'Prefer not to say' => '#888'];
@endphp
@foreach($genderCounts as $label => $count)
@if($count > 0)
<div style="display:flex; align-items:center; gap:9px; margin-bottom:10px;">
<span style="width:10px; height:10px; border-radius:50%; background:{{ $genderColors[$label] }}; flex-shrink:0;"></span>
<i class="bi {{ $genderIcons[$label] }}" style="color:{{ $genderColors[$label] }};"></i>
<span style="flex:1; font-size:13px;">{{ $label }}</span>
<strong style="font-size:14px;">{{ number_format($count) }}</strong>
<span style="font-size:12px; color:var(--text-secondary);">{{ round(($count / $genderTotal) * 100) }}%</span>
</div>
@endif
@endforeach
<div style="font-size:11px; color:var(--text-secondary); margin-top:8px;">Based on {{ number_format($genderTotal) }} logged-in viewer{{ $genderTotal !== 1 ? 's' : '' }} who set their gender.</div>
</div>
</div>
@else
<div class="gender-placeholder">
<i class="bi bi-gender-ambiguous"></i>
<p>No gender data yet. Viewers can set their gender in profile settings.</p>
</div>
@endif
</div>
</div>
<div style="margin-bottom:24px;"></div>
<!-- Recent viewers -->
<div class="an-panel">
<div class="an-panel-title"><i class="bi bi-clock-history"></i> Recent View Events <span style="font-weight:400; font-size:11px; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(last 20 individual plays)</span></div>
@if($recentViews->isNotEmpty())
<div style="overflow-x:auto;">
<table class="viewer-table">
<thead>
<tr>
<th>Viewer</th>
<th>Country</th>
<th>IP Address</th>
<th>Type</th>
<th>Watched At</th>
</tr>
</thead>
<tbody>
@foreach($recentViews as $view)
<tr>
<td>
<div style="display:flex; align-items:center; gap:10px;">
<div class="viewer-avatar">
@if($view->viewer_avatar)
<img src="{{ asset('storage/avatars/' . $view->viewer_avatar) }}" alt="">
@else
<i class="bi bi-person"></i>
@endif
</div>
<span>{{ $view->viewer_name ?? 'Guest' }}</span>
</div>
</td>
<td>
@if($view->country)
<span style="font-size:16px;">{{ flagEmoji($view->country) }}</span>
<span style="font-size:13px; margin-left:4px;">{{ $view->country_name ?? $view->country }}</span>
@else
<span style="color:var(--text-secondary); font-size:12px;">Unknown</span>
@endif
</td>
<td style="font-family:monospace; font-size:12px; color:var(--text-secondary);">
{{ $view->ip_address ?? '—' }}
</td>
<td>
@if($view->user_id)
<span class="auth-badge"><i class="bi bi-person-check"></i> Logged in</span>
@else
<span class="guest-badge"><i class="bi bi-incognito"></i> Guest</span>
@endif
</td>
<td style="color:var(--text-secondary); font-size:12px; white-space:nowrap;">
{{ \Carbon\Carbon::parse($view->watched_at)->diffForHumans() }}
<br><span style="font-size:11px; opacity:.6;">{{ \Carbon\Carbon::parse($view->watched_at)->format('M d, Y H:i') }}</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div style="text-align:center; padding:40px 0; color:var(--text-secondary); font-size:13px;">
No views recorded yet.
</div>
@endif
</div>
@endsection
@section('scripts')
<script>
// ── Shared colours ──────────────────────────────────────────────────────────
const RED = 'rgba(230,30,30,1)';
const RED_BG = 'rgba(230,30,30,0.15)';
const GRID = 'rgba(255,255,255,0.06)';
const TICK = '#888';
Chart.defaults.color = TICK;
Chart.defaults.borderColor = GRID;
// ── Views per day ───────────────────────────────────────────────────────────
new Chart(document.getElementById('dailyChart'), {
type: 'line',
data: {
labels: {!! json_encode($dailyLabels) !!},
datasets: [{
label: 'Views',
data: {!! json_encode($dailyViews) !!},
borderColor: RED,
backgroundColor: RED_BG,
borderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,
pointBackgroundColor: RED,
fill: true,
tension: 0.3,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: GRID }, ticks: { color: TICK, maxTicksLimit: 10 } },
y: { grid: { color: GRID }, ticks: { color: TICK, precision: 0 }, beginAtZero: true }
}
}
});
// ── Country chart ───────────────────────────────────────────────────────────
@if($viewsByCountry->isNotEmpty())
const countryLabels = {!! json_encode($viewsByCountry->map(fn($r) => ($r->country_name ?? $r->country))->values()) !!};
const countryData = {!! json_encode($viewsByCountry->pluck('total')->values()) !!};
const countryMax = Math.max(...countryData);
new Chart(document.getElementById('countryChart'), {
type: 'bar',
data: {
labels: countryLabels,
datasets: [{
data: countryData,
backgroundColor: countryData.map((v, i) => `rgba(230,30,30,${0.9 - (i / countryData.length) * 0.55})`),
borderRadius: 4,
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: GRID }, ticks: { color: TICK, precision: 0 }, beginAtZero: true },
y: { grid: { color: 'transparent' }, ticks: { color: TICK } }
}
}
});
@endif
// ── Age groups ──────────────────────────────────────────────────────────────
@php
$ageLabels = array_keys($ageGroups);
$ageValues = array_values($ageGroups);
$ageColors = ['rgba(33,150,243,0.85)', 'rgba(76,175,80,0.85)', 'rgba(255,152,0,0.85)',
'rgba(233,30,99,0.85)', 'rgba(156,39,176,0.85)', 'rgba(0,188,212,0.85)', 'rgba(120,120,120,0.6)'];
@endphp
@if(array_sum($ageGroups) > 0)
new Chart(document.getElementById('ageChart'), {
type: 'bar',
data: {
labels: {!! json_encode($ageLabels) !!},
datasets: [{
data: {!! json_encode($ageValues) !!},
backgroundColor: {!! json_encode($ageColors) !!},
borderRadius: 5,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: 'transparent' }, ticks: { color: TICK } },
y: { grid: { color: GRID }, ticks: { color: TICK, precision: 0 }, beginAtZero: true }
}
}
});
@endif
// ── Gender donut ─────────────────────────────────────────────────────────────
@php $genderTotal = array_sum($genderCounts); @endphp
@if($genderTotal > 0)
new Chart(document.getElementById('genderChart'), {
type: 'doughnut',
data: {
labels: {!! json_encode(array_keys($genderCounts)) !!},
datasets: [{
data: {!! json_encode(array_values($genderCounts)) !!},
backgroundColor: ['#2196f3', '#e91e63', '#555'],
borderWidth: 0,
hoverOffset: 6,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => ` ${ctx.label}: ${ctx.parsed} (${Math.round(ctx.parsed / {{ $genderTotal }} * 100)}%)`
}
}
}
}
});
@endif
</script>
@endsection
+205 -157
View File
@@ -1,88 +1,96 @@
@extends('admin.layout')
@section('title', 'Video Management')
@section('page_title', 'Video Management')
@section('title', 'Videos')
@section('content')
<!-- Alerts -->
{{-- ── Page header ──────────────────────────────────────────────────── --}}
<div class="adm-page-header">
<h1 class="adm-page-title"><i class="bi bi-play-circle"></i> Videos</h1>
</div>
{{-- ── Alerts ───────────────────────────────────────────────────────── --}}
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="adm-alert adm-alert-success">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
@if(session('error'))
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ session('error') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="adm-alert adm-alert-error">
<i class="bi bi-exclamation-circle-fill"></i>
<span>{{ session('error') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
<!-- Search & Filters -->
<div class="admin-card">
<form method="GET" action="{{ route('admin.videos') }}" class="filter-form">
<div class="form-group">
<label for="search">Search</label>
<input type="text" name="search" id="search" class="form-control" placeholder="Search by title or description..." value="{{ request('search') }}">
</div>
<div class="form-group">
<label for="status">Status</label>
<select name="status" id="status" class="form-select">
{{-- ── Filter card ──────────────────────────────────────────────────── --}}
<div class="adm-card">
<div class="adm-card-body" style="padding:16px 20px;">
<form method="GET" action="{{ route('admin.videos') }}" class="adm-filter-form">
<div class="adm-filter-search">
<i class="bi bi-search"></i>
<input type="text" name="search" class="adm-input"
placeholder="Search title or description…"
value="{{ request('search') }}" autocomplete="off">
</div>
<select name="status" class="adm-select">
<option value="">All Status</option>
<option value="ready" {{ request('status') == 'ready' ? 'selected' : '' }}>Ready</option>
<option value="processing" {{ request('status') == 'processing' ? 'selected' : '' }}>Processing</option>
<option value="pending" {{ request('status') == 'pending' ? 'selected' : '' }}>Pending</option>
<option value="failed" {{ request('status') == 'failed' ? 'selected' : '' }}>Failed</option>
<option value="ready" {{ request('status') === 'ready' ? 'selected' : '' }}>Ready</option>
<option value="processing" {{ request('status') === 'processing' ? 'selected' : '' }}>Processing</option>
<option value="pending" {{ request('status') === 'pending' ? 'selected' : '' }}>Pending</option>
<option value="failed" {{ request('status') === 'failed' ? 'selected' : '' }}>Failed</option>
</select>
</div>
<div class="form-group">
<label for="visibility">Visibility</label>
<select name="visibility" id="visibility" class="form-select">
<select name="visibility" class="adm-select">
<option value="">All Visibility</option>
<option value="public" {{ request('visibility') == 'public' ? 'selected' : '' }}>Public</option>
<option value="unlisted" {{ request('visibility') == 'unlisted' ? 'selected' : '' }}>Unlisted</option>
<option value="private" {{ request('visibility') == 'private' ? 'selected' : '' }}>Private</option>
<option value="public" {{ request('visibility') === 'public' ? 'selected' : '' }}>Public</option>
<option value="unlisted" {{ request('visibility') === 'unlisted' ? 'selected' : '' }}>Unlisted</option>
<option value="private" {{ request('visibility') === 'private' ? 'selected' : '' }}>Private</option>
</select>
</div>
<div class="form-group">
<label for="type">Type</label>
<select name="type" id="type" class="form-select">
<select name="type" class="adm-select">
<option value="">All Types</option>
<option value="generic" {{ request('type') == 'generic' ? 'selected' : '' }}>Generic</option>
<option value="music" {{ request('type') == 'music' ? 'selected' : '' }}>Music</option>
<option value="match" {{ request('type') == 'match' ? 'selected' : '' }}>Match</option>
<option value="generic" {{ request('type') === 'generic' ? 'selected' : '' }}>Generic</option>
<option value="music" {{ request('type') === 'music' ? 'selected' : '' }}>Music</option>
<option value="match" {{ request('type') === 'match' ? 'selected' : '' }}>Match</option>
</select>
</div>
<div class="form-group">
<label for="sort">Sort By</label>
<select name="sort" id="sort" class="form-select">
<option value="latest" {{ request('sort') == 'latest' ? 'selected' : '' }}>Latest First</option>
<option value="oldest" {{ request('sort') == 'oldest' ? 'selected' : '' }}>Oldest First</option>
<option value="title_asc" {{ request('sort') == 'title_asc' ? 'selected' : '' }}>Title (A-Z)</option>
<option value="title_desc" {{ request('sort') == 'title_desc' ? 'selected' : '' }}>Title (Z-A)</option>
<select name="sort" class="adm-select">
<option value="latest" {{ request('sort', 'latest') === 'latest' ? 'selected' : '' }}>Newest first</option>
<option value="oldest" {{ request('sort') === 'oldest' ? 'selected' : '' }}>Oldest first</option>
<option value="title_asc" {{ request('sort') === 'title_asc' ? 'selected' : '' }}>Title A–Z</option>
<option value="title_desc" {{ request('sort') === 'title_desc' ? 'selected' : '' }}>Title Z–A</option>
</select>
</div>
<div class="form-group">
<label>&nbsp;</label>
<button type="submit" class="btn btn-primary">
<i class="bi bi-search"></i> Filter
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-funnel"></i> Filter
</button>
<a href="{{ route('admin.videos') }}" class="btn btn-outline-light">
<i class="bi bi-x-circle"></i> Clear
@if(request()->hasAny(['search','status','visibility','type','sort']))
<a href="{{ route('admin.videos') }}" class="adm-btn">
<i class="bi bi-x-lg"></i> Clear
</a>
</div>
</form>
@endif
</form>
</div>
</div>
<!-- Videos Table -->
<div class="admin-card">
<div class="admin-card-header">
All Videos ({{ $videos->count() }})
{{-- ── Videos table ─────────────────────────────────────────────────── --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title">
<i class="bi bi-play-circle"></i>
All Videos
<span class="adm-badge adm-badge-user">{{ $videos->total() ?? $videos->count() }}</span>
</div>
</div>
<div class="table-responsive">
<table class="admin-table">
<div class="adm-table-wrap">
<table class="adm-table">
<thead>
<tr>
<th>Video</th>
@@ -93,97 +101,128 @@ All Videos ({{ $videos->count() }})
<th>Views</th>
<th>Likes</th>
<th>Uploaded</th>
<th>Actions</th>
<th style="width:110px; text-align:right;">Actions</th>
</tr>
</thead>
<tbody>
@forelse($videos as $video)
<tr>
{{-- Thumbnail + title --}}
<td>
<div class="d-flex align-items-center gap-2">
<div style="display:flex; align-items:center; gap:12px;">
@if($video->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}" style="width: 80px; height: 50px; object-fit: cover; border-radius: 4px;">
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}"
alt="" style="width:72px;height:44px;object-fit:cover;border-radius:6px;flex-shrink:0;border:1px solid var(--border);">
@else
<div style="width: 80px; height: 50px; background: #333; border-radius: 4px; display: flex; align-items: center; justify-content: center;">
<i class="bi bi-play-circle text-secondary"></i>
<div style="width:72px;height:44px;border-radius:6px;background:var(--bg-card2);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i class="bi bi-play-circle" style="color:var(--text-3);font-size:18px;"></i>
</div>
@endif
<div style="max-width: 200px;">
<div style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 500;">{{ $video->title }}</div>
<small class="text-secondary">{{ Str::limit($video->description, 50) }}</small>
<div style="min-width:0;">
<div style="font-weight:500;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;">
{{ $video->title }}
</div>
@if($video->description)
<div style="font-size:11px;color:var(--text-2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;">
{{ Str::limit($video->description, 55) }}
</div>
@endif
</div>
</div>
</td>
{{-- Owner --}}
<td>
<a href="{{ route('channel', $video->user->id) }}" target="_blank" class="text-decoration-none">
<a href="{{ route('channel', $video->user->channel) }}" target="_blank"
style="font-size:13px;color:var(--text);text-decoration:none;display:flex;align-items:center;gap:4px;">
{{ $video->user->name }}
<i class="bi bi-box-arrow-up-right" style="font-size:10px;opacity:.4;"></i>
</a>
</td>
{{-- Status --}}
<td>
@switch($video->status)
@case('ready')
<span class="badge-status badge-ready">Ready</span>
@break
@case('processing')
<span class="badge-status badge-processing">Processing</span>
@break
@case('pending')
<span class="badge-status badge-pending">Pending</span>
@break
@case('failed')
<span class="badge-status badge-failed">Failed</span>
@break
@endswitch
@php
$statusMap = [
'ready' => ['adm-badge-ready', 'bi-check-circle-fill', 'Ready'],
'processing' => ['adm-badge-unlisted','bi-arrow-repeat', 'Processing'],
'pending' => ['adm-badge-unverified','bi-clock', 'Pending'],
'failed' => ['adm-badge-superadmin','bi-x-circle-fill', 'Failed'],
];
[$cls, $ico, $lbl] = $statusMap[$video->status] ?? ['adm-badge-user','bi-dash','Unknown'];
@endphp
<span class="adm-badge {{ $cls }}"><i class="bi {{ $ico }}"></i> {{ $lbl }}</span>
</td>
{{-- Visibility --}}
<td>
@switch($video->visibility)
@case('public')
<span class="badge-status badge-public">Public</span>
@break
@case('unlisted')
<span class="badge-status badge-unlisted">Unlisted</span>
@break
@case('private')
<span class="badge-status badge-private">Private</span>
@break
@endswitch
@php
$visMap = [
'public' => ['adm-badge-ready', 'bi-globe2', 'Public'],
'unlisted' => ['adm-badge-unlisted','bi-link-45deg', 'Unlisted'],
'private' => ['adm-badge-private', 'bi-lock-fill', 'Private'],
];
[$vcls, $vico, $vlbl] = $visMap[$video->visibility] ?? ['adm-badge-user','bi-question','—'];
@endphp
<span class="adm-badge {{ $vcls }}"><i class="bi {{ $vico }}"></i> {{ $vlbl }}</span>
</td>
{{-- Type --}}
<td>
<span class="text-capitalize">{{ $video->type }}</span>
@php
$typeMap = [
'generic' => ['bi-play-circle','#94a3b8'],
'music' => ['bi-music-note-beamed','#a78bfa'],
'match' => ['bi-trophy','#fbbf24'],
];
[$tico, $tcol] = $typeMap[$video->type] ?? ['bi-play-circle','#94a3b8'];
@endphp
<span style="display:inline-flex;align-items:center;gap:5px;font-size:12px;color:{{ $tcol }};">
<i class="bi {{ $tico }}"></i>
{{ ucfirst($video->type) }}
</span>
</td>
<td>{{ number_format(\DB::table('video_views')->where('video_id', $video->id)->count()) }}</td>
<td>{{ number_format(\DB::table('video_likes')->where('video_id', $video->id)->count()) }}</td>
<td>{{ $video->created_at->format('M d, Y') }}</td>
{{-- Views --}}
<td style="font-size:13px;color:var(--text-2);">
{{ number_format(\DB::table('video_views')->where('video_id',$video->id)->count()) }}
</td>
{{-- Likes --}}
<td style="font-size:13px;color:var(--text-2);">
{{ number_format(\DB::table('video_likes')->where('video_id',$video->id)->count()) }}
</td>
{{-- Date --}}
<td class="text-muted-sm">{{ $video->created_at->format('M d, Y') }}</td>
{{-- Actions --}}
<td>
<div class="dropdown">
<button class="btn btn-sm btn-outline-light dropdown-toggle" data-bs-toggle="dropdown">
<i class="bi bi-gear"></i>
<div class="adm-row-actions" style="justify-content:flex-end;">
<a href="{{ route('videos.show', $video) }}" target="_blank"
class="adm-btn adm-btn-sm" title="Watch">
<i class="bi bi-play"></i>
</a>
<a href="{{ route('admin.videos.edit', $video) }}"
class="adm-btn adm-btn-sm" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<button type="button"
class="adm-btn adm-btn-sm adm-btn-danger"
title="Delete"
onclick="openDeleteDialog('{{ $video->getRouteKey() }}', '{{ addslashes($video->title) }}')">
<i class="bi bi-trash"></i>
</button>
<ul class="dropdown-menu dropdown-menu-dark">
<li>
<a class="dropdown-item" href="{{ route('videos.show', $video->id) }}" target="_blank">
<i class="bi bi-play-circle"></i> View
</a>
</li>
<li>
<a class="dropdown-item" href="{{ route('admin.videos.edit', $video->id) }}">
<i class="bi bi-pencil"></i> Edit
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<button class="dropdown-item text-danger" onclick="confirmDeleteVideo({{ $video->id }}, '{{ $video->title }}')">
<i class="bi bi-trash"></i> Delete
</button>
</li>
</ul>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="9" class="text-center text-secondary py-4">
No videos found
<td colspan="9">
<div class="empty-state">
<i class="bi bi-play-circle"></i>
<p>No videos found</p>
</div>
</td>
</tr>
@endforelse
@@ -191,40 +230,42 @@ All Videos ({{ $videos->count() }})
</table>
</div>
<!-- Pagination -->
<div class="d-flex justify-content-center">
{{ $videos->links() }}
{{-- Pagination --}}
@if($videos instanceof \Illuminate\Pagination\LengthAwarePaginator && $videos->hasPages())
<div style="padding:16px 20px; border-top:1px solid var(--border);">
{{ $videos->onEachSide(1)->links() }}
</div>
@endif
</div>
<!-- Delete Video Modal -->
<div class="modal fade" id="deleteVideoModal" tabindex="-1" aria-labelledby="deleteVideoModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content" style="background: #1a1a1a; border: 1px solid #3f3f3f; border-radius: 12px;">
<div class="modal-header" style="border-bottom: 1px solid #3f3f3f; padding: 20px 24px;">
<h5 class="modal-title" id="deleteVideoModalLabel" style="color: #fff; font-weight: 600;">
<i class="bi bi-exclamation-triangle-fill text-danger me-2"></i>
Delete Video
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
{{-- ── Delete confirmation dialog ───────────────────────────────────── --}}
<div class="adm-dialog-overlay" id="deleteDialog">
<div class="adm-dialog">
<div class="adm-dialog-header">
<div class="adm-dialog-title">
<i class="bi bi-exclamation-triangle-fill"></i> Delete Video
</div>
<div class="modal-body" style="padding: 24px;">
<p>Are you sure you want to delete this video? This action cannot be undone.</p>
<p><strong>Video:</strong> <span id="deleteVideoTitle"></span></p>
<div class="alert alert-warning">
<i class="bi bi-info-circle me-2"></i>
This will also delete all likes and views associated with this video.
</div>
</div>
<div class="modal-footer" style="border-top: 1px solid #3f3f3f; padding: 16px 24px;">
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>
<form id="deleteVideoForm" method="POST">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger">Delete Video</button>
</form>
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDeleteDialog()"
style="border:none;background:none;color:var(--text-2);">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="adm-dialog-body">
<p>You are about to permanently delete <strong id="dlgVideoTitle"></strong>.</p>
<div class="adm-dialog-warning">
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
All views, likes, comments, and HLS files for this video will also be deleted.
</div>
</div>
<div class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDeleteDialog()">Cancel</button>
<form id="deleteForm" method="POST">
@csrf @method('DELETE')
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
<i class="bi bi-trash"></i> Delete Video
</button>
</form>
</div>
</div>
</div>
@@ -232,12 +273,19 @@ All Videos ({{ $videos->count() }})
@section('scripts')
<script>
function confirmDeleteVideo(videoId, videoTitle) {
document.getElementById('deleteVideoTitle').textContent = videoTitle;
document.getElementById('deleteVideoForm').action = '/admin/videos/' + videoId;
const modal = new bootstrap.Modal(document.getElementById('deleteVideoModal'));
modal.show();
}
function openDeleteDialog(videoId, videoTitle) {
document.getElementById('dlgVideoTitle').textContent = videoTitle;
document.getElementById('deleteForm').action = '/admin/videos/' + videoId;
document.getElementById('deleteDialog').classList.add('open');
}
function closeDeleteDialog() {
document.getElementById('deleteDialog').classList.remove('open');
}
document.getElementById('deleteDialog').addEventListener('click', function(e) {
if (e.target === this) closeDeleteDialog();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeDeleteDialog();
});
</script>
@endsection
@@ -0,0 +1,31 @@
@extends('layouts.auth')
@section('title', 'Two-Factor Authentication | ' . config('app.name'))
@section('content')
<div class="auth-card">
<h1 class="auth-title">Two-Factor Authentication</h1>
<p class="auth-subtitle">Enter the 6-digit code from your authenticator app</p>
@if($errors->any())
<div class="auth-error">
<i class="bi bi-exclamation-circle-fill"></i>
<span>{{ $errors->first() }}</span>
</div>
@endif
<form method="POST" action="{{ route('2fa.verify') }}">
@csrf
<div class="form-group">
<label class="form-label">Authentication Code</label>
<input type="text" name="code" class="form-input"
inputmode="numeric" pattern="[0-9]*" maxlength="6"
placeholder="000000" autofocus autocomplete="one-time-code"
style="letter-spacing: 0.3em; font-size: 22px; text-align: center;">
</div>
<button type="submit" class="btn-primary">Verify</button>
</form>
</div>
@endsection
+45 -163
View File
@@ -1,169 +1,51 @@
@extends('layouts.plain')
@extends('layouts.auth')
@section('title', 'Login | ' . config('app.name'))
@section('extra_styles')
<style>
.auth-container {
width: 100%;
max-width: 400px;
margin: 0 auto;
}
.auth-card {
background: var(--bg-secondary);
border-radius: 12px;
padding: 32px;
}
.auth-logo {
text-align: center;
margin-bottom: 24px;
}
.auth-logo a {
font-size: 28px;
font-weight: 700;
color: var(--brand-red);
text-decoration: none;
}
.auth-title {
text-align: center;
font-size: 24px;
font-weight: 500;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}
.form-input {
width: 100%;
background: #121212;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px 16px;
color: var(--text-primary);
font-size: 14px;
}
.form-input:focus {
outline: none;
border-color: var(--brand-red);
}
.btn-primary {
width: 100%;
background: var(--brand-red);
color: white;
border: none;
padding: 14px 24px;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
margin-top: 8px;
}
.btn-primary:hover {
background: #cc1a1a;
}
.auth-footer {
text-align: center;
margin-top: 24px;
color: var(--text-secondary);
}
.auth-footer a {
color: var(--brand-red);
text-decoration: none;
}
.auth-footer a:hover {
text-decoration: underline;
}
.error-message {
background: rgba(239, 68, 68, 0.2);
border: 1px solid #ef4444;
color: #ef4444;
padding: 12px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 14px;
}
.form-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
}
.form-checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--brand-red);
}
</style>
@endsection
@section('title', 'Sign in | ' . config('app.name'))
@section('content')
<div class="auth-container">
<div class="auth-card">
<div class="auth-logo">
<a href="/videos">
<img src="{{ asset('storage/images/fullLogo.png') }}" alt="{{ config('app.name') }}" style="height: 40px;">
</a>
</div>
<h1 class="auth-title">Sign in</h1>
@if($errors->any())
<div class="error-message">
{{ $errors->first() }}
</div>
@endif
<form method="POST" action="{{ route('login.store') }}">
@csrf
<div class="form-group">
<label class="form-label">Email</label>
<input type="email" name="email" class="form-input" required autofocus>
</div>
<div class="form-group">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-input" required>
</div>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" name="remember" value="true">
Remember me
</label>
</div>
<button type="submit" class="btn-primary">Sign in</button>
</form>
<div class="auth-footer">
Don't have an account? <a href="{{ route('register') }}">Sign up</a>
</div>
<div class="auth-card">
<h1 class="auth-title">Welcome back</h1>
<p class="auth-subtitle">Sign in to your account</p>
@if(session('info'))
<div class="auth-error" style="background:rgba(59,130,246,.1);border-color:rgba(59,130,246,.35);color:#93c5fd;">
<i class="bi bi-info-circle-fill"></i>
<span>{{ session('info') }}</span>
</div>
</div>
@endif
@if($errors->any())
<div class="auth-error">
<i class="bi bi-exclamation-circle-fill"></i>
<span>{{ $errors->first() }}</span>
</div>
@endif
<form method="POST" action="{{ route('login.store') }}">
@csrf
<div class="form-group">
<label class="form-label">Email</label>
<input type="email" name="email" class="form-input" value="{{ old('email') }}" placeholder="[email protected]" required autofocus>
</div>
<div class="form-group">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-input" placeholder="••••••••" required>
</div>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" name="remember" value="true" checked>
Remember me
</label>
</div>
<button type="submit" class="btn-primary">Sign in</button>
</form>
</div>
@endsection
@section('footer_link')
Don't have an account? <a href="{{ route('register') }}">Create one</a>
@endsection
+58 -151
View File
@@ -1,157 +1,64 @@
@extends('layouts.plain')
@extends('layouts.auth')
@section('title', 'Register | ' . config('app.name'))
@section('extra_styles')
<style>
.auth-container {
width: 100%;
max-width: 400px;
margin: 0 auto;
}
.auth-card {
background: var(--bg-secondary);
border-radius: 12px;
padding: 32px;
}
.auth-logo {
text-align: center;
margin-bottom: 24px;
}
.auth-logo a {
font-size: 28px;
font-weight: 700;
color: var(--brand-red);
text-decoration: none;
}
.auth-title {
text-align: center;
font-size: 24px;
font-weight: 500;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}
.form-input {
width: 100%;
background: #121212;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px 16px;
color: var(--text-primary);
font-size: 14px;
}
.form-input:focus {
outline: none;
border-color: var(--brand-red);
}
.btn-primary {
width: 100%;
background: var(--brand-red);
color: white;
border: none;
padding: 14px 24px;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
margin-top: 8px;
}
.btn-primary:hover {
background: #cc1a1a;
}
.auth-footer {
text-align: center;
margin-top: 24px;
color: var(--text-secondary);
}
.auth-footer a {
color: var(--brand-red);
text-decoration: none;
}
.auth-footer a:hover {
text-decoration: underline;
}
.error-message {
background: rgba(239, 68, 68, 0.2);
border: 1px solid #ef4444;
color: #ef4444;
padding: 12px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 14px;
}
</style>
@endsection
@section('title', 'Create Account | ' . config('app.name'))
@section('content')
<div class="auth-container">
<div class="auth-card">
<div class="auth-logo">
<a href="/videos">
<img src="{{ asset('storage/images/fullLogo.png') }}" alt="{{ config('app.name') }}" style="height: 40px;">
</a>
</div>
<h1 class="auth-title">Create Account</h1>
@if($errors->any())
<div class="error-message">
{{ $errors->first() }}
</div>
@endif
<form method="POST" action="{{ route('register.store') }}">
@csrf
<div class="form-group">
<label class="form-label">Name</label>
<input type="text" name="name" class="form-input" required autofocus>
</div>
<div class="form-group">
<label class="form-label">Email</label>
<input type="email" name="email" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">Confirm Password</label>
<input type="password" name="password_confirmation" class="form-input" required>
</div>
<button type="submit" class="btn-primary">Create Account</button>
</form>
<div class="auth-footer">
Already have an account? <a href="{{ route('login') }}">Sign in</a>
</div>
<div class="auth-card">
<h1 class="auth-title">Create account</h1>
<p class="auth-subtitle">Join {{ config('app.name') }} today</p>
@if($errors->any())
<div class="auth-error">
<i class="bi bi-exclamation-circle-fill"></i>
<span>{{ $errors->first() }}</span>
</div>
</div>
@endif
<form method="POST" action="{{ route('register.store') }}">
@csrf
<div class="form-group">
<label class="form-label">Name</label>
<input type="text" name="name" class="form-input" value="{{ old('name') }}" placeholder="Your name" required autofocus>
</div>
<div class="form-group">
<label class="form-label">Email</label>
<input type="email" name="email" class="form-input" value="{{ old('email') }}" placeholder="[email protected]" required>
</div>
<div class="form-group">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-input" placeholder="At least 8 characters" required>
</div>
<div class="form-group">
<label class="form-label">Confirm Password</label>
<input type="password" name="password_confirmation" class="form-input" placeholder="••••••••" required>
</div>
<div class="form-group">
<x-date-picker name="birthday" label="Date of Birth" :value="old('birthday')" required />
</div>
<div class="form-group">
<x-gender-select name="gender" label="Gender" :value="old('gender')" required />
</div>
<div class="form-group">
<x-country-select name="nationality" label="Nationality" :value="old('nationality')" placeholder="Select nationality" required />
</div>
{{-- Honeypot: hidden from real users, bots fill it in --}}
<div style="position:absolute;left:-9999px;top:-9999px;height:0;overflow:hidden;" aria-hidden="true">
<input type="text" name="_hp" value="" autocomplete="off" tabindex="-1">
</div>
<button type="submit" class="btn-primary">Create Account</button>
</form>
</div>
@endsection
@section('footer_link')
Already have an account? <a href="{{ route('login') }}">Sign in</a>
@endsection
@@ -0,0 +1,37 @@
@extends('layouts.auth')
@section('title', 'Verify Email | ' . config('app.name'))
@section('content')
<div class="auth-card" style="text-align:center;">
<div style="font-size:48px; margin-bottom:20px;">📧</div>
<h1 class="auth-title">Check your email</h1>
<p class="auth-subtitle" style="margin-bottom:24px; line-height:1.6;">
We sent a verification link to your email address.<br>
Click it to activate your account.
</p>
@if(session('resent'))
<div class="auth-error" style="background:rgba(34,197,94,.1);border-color:rgba(34,197,94,.35);color:#86efac; margin-bottom:20px;">
<i class="bi bi-check-circle-fill"></i>
<span>A new verification link has been sent.</span>
</div>
@endif
<form method="POST" action="{{ route('verification.send') }}">
@csrf
<button type="submit" class="btn-primary" style="margin-top:0;">
<i class="bi bi-arrow-repeat"></i> Resend verification email
</button>
</form>
<div style="margin-top:20px; font-size:13px; color:var(--text-secondary);">
Wrong account?
<form method="POST" action="{{ route('logout') }}" style="display:inline;">
@csrf
<button type="submit" style="background:none;border:none;color:var(--brand-red);cursor:pointer;font-size:13px;padding:0;font-family:inherit;">Sign out</button>
</form>
</div>
</div>
@endsection
@@ -1,6 +1,6 @@
<div class="channel-row">
<div style="display: flex; align-items: center; gap: 12px;">
<a href="{{ route('channel', $video->user_id) }}" class="channel-info text-decoration-none"
<a href="{{ $video->user ? route('channel', $video->user->channel) : '#' }}" class="channel-info text-decoration-none"
style="color: inherit; display: flex; align-items: center; gap: 12px;">
@if ($video->user)
<img src="{{ $video->user->avatar_url }}" class="channel-avatar" alt="{{ $video->user->name }}">
@@ -9,7 +9,9 @@
@endif
<div>
<div class="channel-name">{{ $video->user->name ?? 'Unknown' }}</div>
<div class="channel-subs">{{ number_format($video->user->subscriber_count ?? 0) }} subscribers</div>
<div class="channel-subs"
data-channel-id="{{ $video->user_id }}"
data-count="{{ $video->user->subscriber_count ?? 0 }}">{{ number_format($video->user->subscriber_count ?? 0) }} subscribers</div>
</div>
</a>
</div>
@@ -52,13 +54,30 @@
/* Responsive */
@media (max-width: 576px) {
.channel-row {
flex-direction: column;
align-items: flex-start !important;
gap: 12px;
flex-direction: row;
align-items: center;
flex-wrap: nowrap;
gap: 8px;
}
.channel-row > div:first-child {
flex: 1;
min-width: 0;
}
.channel-info {
width: 100%;
min-width: 0;
}
.channel-name {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.channel-subs {
white-space: nowrap;
}
}
</style>
@@ -0,0 +1,234 @@
@props([
'name' => '',
'id' => null,
'value' => null,
'label' => null,
'placeholder' => 'Select country',
'required' => false,
'class' => '',
'style' => '',
'autoGeo' => true,
])
@php
use App\Data\Countries;
$options = Countries::forCountry();
$uid = 'csd_' . ($id ?? $name) . '_' . substr(md5(uniqid()), 0, 8);
$inputId = $id ?? $name;
$selFlag = '🌐';
$selLabel = $placeholder;
if ($value) {
foreach ($options as $opt) {
if ($opt['value'] === $value) {
$selFlag = $opt['flag'];
$selLabel = $opt['label'];
break;
}
}
}
$isPlaceholder = !$value;
// Embed geo map only when no value is pre-set and auto-detection is enabled
$geoMapJson = ($autoGeo && !$value) ? json_encode(Countries::forGeoTimezoneMap()) : null;
@endphp
@once('csd-styles')
<style>
.csd-wrap { position: relative; }
.csd-lbl { display: block; margin-bottom: 6px; font-weight: 500; font-size: 14px; color: var(--text-primary, #f1f1f1); }
.csd-lbl .req { color: var(--brand-red, #e61e1e); margin-left: 2px; }
.csd-btn {
display: flex; align-items: center; gap: 8px; width: 100%;
background: var(--bg-dark, #0f0f0f); border: 1px solid var(--border-color, #303030);
border-radius: 8px; padding: 10px 14px; color: var(--text-primary, #f1f1f1);
font-size: 14px; font-family: inherit; line-height: 1.4; cursor: pointer;
text-align: left; transition: border-color .2s; outline: none; min-height: 46px; box-sizing: border-box;
}
.csd-btn:hover, .csd-btn:focus-visible { border-color: var(--brand-red, #e61e1e); }
.csd-btn[aria-expanded="true"] { border-color: var(--brand-red, #e61e1e); }
.csd-ico { font-size: 18px; line-height: 1; flex-shrink: 0; user-select: none; }
.csd-val { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.csd-val.ph { color: var(--text-secondary, #aaa); }
.csd-sub { font-size: 12px; color: var(--text-secondary, #aaa); white-space: nowrap; flex-shrink: 0; }
.csd-arr { flex-shrink: 0; font-size: 11px; color: var(--text-secondary, #aaa); margin-left: 4px; transition: transform .2s; }
.csd-btn[aria-expanded="true"] .csd-arr { transform: rotate(180deg); }
.csd-panel {
position: fixed; z-index: 1060; min-width: 220px;
background: var(--bg-secondary, #1e1e1e); border: 1px solid var(--border-color, #303030);
border-radius: 10px; box-shadow: 0 12px 32px rgba(0,0,0,.55); overflow: hidden;
}
.csd-srch { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border-color, #303030); }
.csd-srch i { font-size: 13px; color: var(--text-secondary, #aaa); flex-shrink: 0; }
.csd-sinput { flex: 1; background: none; border: none; outline: none; color: var(--text-primary, #f1f1f1); font-size: 13px; font-family: inherit; }
.csd-sinput::placeholder { color: var(--text-secondary, #aaa); }
.csd-list { list-style: none; margin: 0; padding: 4px 0; max-height: 240px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--border-color, #303030) transparent; }
.csd-list::-webkit-scrollbar { width: 4px; }
.csd-list::-webkit-scrollbar-thumb { background: var(--border-color, #303030); border-radius: 2px; }
.csd-opt { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; font-size: 13px; color: var(--text-primary, #f1f1f1); transition: background .1s; outline: none; }
.csd-opt:hover, .csd-opt:focus { background: rgba(255,255,255,.07); }
.csd-opt[aria-selected="true"] { background: rgba(255,255,255,.05); }
.csd-opt-ico { font-size: 17px; line-height: 1; flex-shrink: 0; user-select: none; }
.csd-opt-main { font-weight: 500; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.csd-opt-sub { font-size: 12px; color: var(--text-secondary, #aaa); white-space: nowrap; flex-shrink: 0; max-width: 45%; overflow: hidden; text-overflow: ellipsis; }
.csd-empty { padding: 12px; font-size: 13px; color: var(--text-secondary, #aaa); text-align: center; }
</style>
@endonce
@once('csd-script')
<script>
(function () {
if (window.CSD) return;
window.CSD = class {
constructor(id) {
this.root = document.getElementById(id);
this.btn = this.root.querySelector('.csd-btn');
this.panel = this.root.querySelector('.csd-panel');
this.sinput = this.root.querySelector('.csd-sinput');
this.list = this.root.querySelector('.csd-list');
this.hidden = this.root.querySelector('input[type=hidden]');
this.icoEl = this.btn.querySelector('.csd-ico');
this.valEl = this.btn.querySelector('.csd-val');
this.subEl = this.btn.querySelector('.csd-sub');
this.emptyEl= this.root.querySelector('.csd-empty');
this._bind();
if (this.root.dataset.geoMap && !this.hidden.value) {
this._autoGeo();
}
}
_bind() {
this.btn.addEventListener('click', e => { e.stopPropagation(); this.toggle(); });
this.sinput.addEventListener('input', () => this._filter());
this.list.addEventListener('click', e => { const o = e.target.closest('.csd-opt'); if (o) this._pick(o); });
document.addEventListener('click', e => { if (!this.root.contains(e.target)) this.close(); }, true);
this.btn.addEventListener('keydown', e => { if (e.key==='Enter'||e.key===' ') { e.preventDefault(); this.toggle(); } });
this.sinput.addEventListener('keydown', e => {
if (e.key==='Escape') { this.close(); this.btn.focus(); }
if (e.key==='ArrowDown') { e.preventDefault(); this._move(0); }
if (e.key==='Enter') { e.preventDefault(); }
});
this.list.addEventListener('keydown', e => {
const all = [...this.list.querySelectorAll('.csd-opt:not([hidden])')];
const i = all.indexOf(document.activeElement.closest('.csd-opt'));
if (e.key==='ArrowDown') { e.preventDefault(); this._move(i+1); }
if (e.key==='ArrowUp') { e.preventDefault(); i>0 ? this._move(i-1) : this.sinput.focus(); }
if (e.key==='Enter') { e.preventDefault(); if (all[i]) this._pick(all[i]); }
if (e.key==='Escape') { this.close(); this.btn.focus(); }
});
}
_autoGeo() {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const map = JSON.parse(this.root.dataset.geoMap);
const iso2 = map[tz];
if (iso2) {
const opt = this.list.querySelector('[data-v="' + iso2 + '"]');
if (opt) this._pick(opt);
}
} catch (_) {}
}
toggle() { this.panel.hidden ? this.open() : this.close(); }
open() {
document.querySelectorAll('.csd-panel:not([hidden])').forEach(p => { p.hidden = true; });
document.querySelectorAll('.csd-btn[aria-expanded=true]').forEach(b => b.setAttribute('aria-expanded','false'));
this.panel.hidden = false;
this.btn.setAttribute('aria-expanded','true');
this.sinput.value = '';
this._filter();
requestAnimationFrame(() => this.sinput.focus());
const r = this.btn.getBoundingClientRect();
const goUp = window.innerHeight - r.bottom < 280 && r.top > 280;
this.panel.style.left = r.left + 'px';
this.panel.style.width = Math.max(r.width, 220) + 'px';
if (goUp) {
this.panel.style.top = '';
this.panel.style.bottom = (window.innerHeight - r.top + 4) + 'px';
} else {
this.panel.style.top = (r.bottom + 4) + 'px';
this.panel.style.bottom = '';
}
}
close() { this.panel.hidden=true; this.btn.setAttribute('aria-expanded','false'); }
_filter() {
const q = this.sinput.value.toLowerCase(); let n=0;
this.list.querySelectorAll('.csd-opt').forEach(o => {
const show = o.dataset.s.includes(q);
o.style.display = show ? '' : 'none';
if (show) n++;
});
if (this.emptyEl) this.emptyEl.style.display = n > 0 ? 'none' : '';
}
_move(i) { const all=[...this.list.querySelectorAll('.csd-opt:not([hidden])')]; if(all[i]){all[i].tabIndex=0;all[i].focus();} }
_pick(opt) {
this.hidden.value = opt.dataset.v;
this.icoEl.textContent = opt.querySelector('.csd-opt-ico').textContent;
this.valEl.textContent = opt.querySelector('.csd-opt-main').textContent;
this.valEl.classList.remove('ph');
if (this.subEl) { const s=opt.querySelector('.csd-opt-sub'); this.subEl.textContent=s?s.textContent:''; }
this.list.querySelectorAll('.csd-opt').forEach(o => o.setAttribute('aria-selected', o===opt?'true':'false'));
this.close(); this.btn.focus();
this.hidden.dispatchEvent(new Event('change',{bubbles:true}));
}
};
}());
</script>
@endonce
<div class="csd-wrap {{ $class }}" id="{{ $uid }}"
@if($style) style="{{ $style }}" @endif
@if($geoMapJson) data-geo-map="{{ $geoMapJson }}" @endif>
@if($label)
<label class="csd-lbl" for="{{ $inputId }}">
{{ $label }}@if($required)<span class="req">*</span>@endif
</label>
@endif
<button type="button"
class="csd-btn"
aria-haspopup="listbox"
aria-expanded="false"
aria-label="{{ $label ?? 'Select country' }}">
<span class="csd-ico">{{ $selFlag }}</span>
<span class="csd-val{{ $isPlaceholder ? ' ph' : '' }}">{{ $selLabel }}</span>
<i class="bi bi-chevron-down csd-arr"></i>
</button>
<div class="csd-panel" hidden role="listbox">
<div class="csd-srch">
<i class="bi bi-search"></i>
<input class="csd-sinput" type="text" placeholder="Search country…" autocomplete="off" spellcheck="false">
</div>
<ul class="csd-list">
@foreach($options as $opt)
<li class="csd-opt"
role="option"
tabindex="-1"
data-v="{{ $opt['value'] }}"
data-s="{{ $opt['search'] }}"
aria-selected="{{ $value === $opt['value'] ? 'true' : 'false' }}">
<span class="csd-opt-ico">{{ $opt['flag'] }}</span>
<span class="csd-opt-main">{{ $opt['label'] }}</span>
</li>
@endforeach
</ul>
<p class="csd-empty" hidden>No results</p>
</div>
<input type="hidden"
name="{{ $name }}"
id="{{ $inputId }}"
value="{{ $value }}"
@if($required) required @endif>
</div>
<script>
(function () {
function boot() { new CSD('{{ $uid }}'); }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}());
</script>
@@ -0,0 +1,416 @@
@props([
'name' => '',
'id' => null,
'value' => null,
'label' => null,
'required' => false,
'class' => '',
'style' => '',
'minYear' => 1900,
'maxYear' => null,
])
@php
$maxYear = (int) ($maxYear ?? date('Y'));
$minYear = (int) $minYear;
$uid = 'dp_' . ($id ?? $name) . '_' . substr(md5(uniqid()), 0, 8);
$inputId = $id ?? $name;
// Parse YYYY-MM-DD initial value
$initDay = $initMonth = $initYear = null;
if ($value && preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $m)) {
[, $initYear, $initMonth, $initDay] = array_map('intval', $m);
}
$months = ['January','February','March','April','May','June',
'July','August','September','October','November','December'];
@endphp
{{-- ── Shared CSS ────────────────────────────────────────────────────── --}}
@once('dp-styles')
<style>
.dp-wrap { position: relative; }
.dp-lbl { display: block; margin-bottom: 6px; font-weight: 500; font-size: 14px; color: var(--text-primary, #f1f1f1); }
.dp-lbl .req { color: var(--brand-red, #e61e1e); margin-left: 2px; }
.dp-row { display: flex; gap: 8px; }
/* Each column */
.dp-field { position: relative; flex: 1; min-width: 0; }
.dp-field.dp-day { flex: 0 0 72px; }
.dp-field.dp-year { flex: 0 0 98px; }
/* Trigger button — matches .form-input style */
.dp-btn {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
width: 100%;
background: var(--bg-dark, #0f0f0f);
border: 1px solid var(--border-color, #303030);
border-radius: 8px;
padding: 10px 10px;
color: var(--text-primary, #f1f1f1);
font-size: 14px;
font-family: inherit;
line-height: 1.4;
cursor: pointer;
text-align: left;
transition: border-color .2s;
outline: none;
min-height: 46px;
box-sizing: border-box;
white-space: nowrap;
overflow: hidden;
}
.dp-btn:hover,
.dp-btn:focus-visible { border-color: var(--brand-red, #e61e1e); }
.dp-btn[aria-expanded="true"] { border-color: var(--brand-red, #e61e1e); }
.dp-val { flex: 1; overflow: hidden; text-overflow: ellipsis; }
.dp-val.ph { color: var(--text-secondary, #aaa); }
.dp-arr { flex-shrink: 0; font-size: 10px; color: var(--text-secondary, #aaa); transition: transform .2s; }
.dp-btn[aria-expanded="true"] .dp-arr { transform: rotate(180deg); }
/* Dropdown panel */
.dp-panel {
position: fixed;
z-index: 1060;
background: var(--bg-secondary, #1e1e1e);
border: 1px solid var(--border-color, #303030);
border-radius: 10px;
box-shadow: 0 12px 32px rgba(0,0,0,.55);
overflow: hidden;
}
/* Year search bar */
.dp-srch {
display: flex;
align-items: center;
gap: 7px;
padding: 7px 10px;
border-bottom: 1px solid var(--border-color, #303030);
}
.dp-srch i { font-size: 12px; color: var(--text-secondary, #aaa); flex-shrink: 0; }
.dp-sinput {
flex: 1; width: 100%;
background: none; border: none; outline: none;
color: var(--text-primary, #f1f1f1);
font-size: 13px; font-family: inherit;
}
.dp-sinput::placeholder { color: var(--text-secondary, #aaa); }
/* Shared list */
.dp-list {
list-style: none;
margin: 0; padding: 4px 0;
max-height: 220px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-color, #303030) transparent;
}
.dp-list::-webkit-scrollbar { width: 4px; }
.dp-list::-webkit-scrollbar-thumb { background: var(--border-color, #303030); border-radius: 2px; }
/* Month / Year options (list style) */
.dp-list-opt {
display: flex;
align-items: center;
padding: 8px 13px;
cursor: pointer;
font-size: 13px;
color: var(--text-primary, #f1f1f1);
transition: background .1s;
outline: none;
}
.dp-list-opt:hover { background: rgba(255,255,255,.07); }
.dp-list-opt[aria-selected="true"] {
background: rgba(230,30,30,.12);
color: var(--brand-red, #e61e1e);
font-weight: 600;
}
/* Day grid */
.dp-day-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 3px;
padding: 8px;
max-height: none;
overflow: visible;
}
.dp-day-opt {
display: flex;
align-items: center;
justify-content: center;
padding: 7px 2px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: var(--text-primary, #f1f1f1);
transition: background .1s;
outline: none;
user-select: none;
}
.dp-day-opt:hover { background: rgba(255,255,255,.1); }
.dp-day-opt[aria-selected="true"] {
background: var(--brand-red, #e61e1e);
color: #fff;
font-weight: 700;
}
.dp-day-opt[hidden] { display: none; }
.dp-list-opt[hidden] { display: none; }
</style>
@endonce
{{-- ── Shared JS ─────────────────────────────────────────────────────── --}}
@once('dp-script')
<script>
(function () {
if (window.DatePicker) return;
window.DatePicker = class {
constructor(id) {
this.root = document.getElementById(id);
this.hidden = this.root.querySelector('input[type=hidden]');
this.f = {
day: this.root.querySelector('.dp-day'),
month: this.root.querySelector('.dp-month'),
year: this.root.querySelector('.dp-year'),
};
this.v = { day: null, month: null, year: null };
// Restore from hidden value
const raw = this.hidden.value;
if (raw && /^\d{4}-\d{2}-\d{2}$/.test(raw)) {
const [y, m, d] = raw.split('-').map(Number);
this.v = { year: y, month: m, day: d };
}
this._bind('day');
this._bind('month');
this._bind('year');
// Apply initial day constraints
if (this.v.month) this._constrainDays();
document.addEventListener('click', e => {
if (!this.root.contains(e.target)) this._closeAll();
}, true);
}
_bind(type) {
const field = this.f[type];
const btn = field.querySelector('.dp-btn');
const panel = field.querySelector('.dp-panel');
const list = field.querySelector('[data-dp-list]');
const sinput = field.querySelector('.dp-sinput');
btn.addEventListener('click', e => {
e.stopPropagation();
const wasOpen = !panel.hidden;
this._closeAll();
if (wasOpen) return;
panel.hidden = false;
btn.setAttribute('aria-expanded', 'true');
if (sinput) {
sinput.value = '';
this._filterYear(list, '');
requestAnimationFrame(() => sinput.focus());
}
// Scroll selected option into view
requestAnimationFrame(() => {
const sel = list.querySelector('[aria-selected="true"]');
if (sel) sel.scrollIntoView({ block: 'nearest' });
});
// Position panel using fixed coords to escape any overflow container
const r = btn.getBoundingClientRect();
const minW = type === 'day' ? 196 : r.width;
const goUp = window.innerHeight - r.bottom < 270 && r.top > 270;
panel.style.left = r.left + 'px';
panel.style.width = Math.max(r.width, minW) + 'px';
if (goUp) {
panel.style.top = '';
panel.style.bottom = (window.innerHeight - r.top + 4) + 'px';
} else {
panel.style.top = (r.bottom + 4) + 'px';
panel.style.bottom = '';
}
});
list.addEventListener('click', e => {
const opt = e.target.closest('[data-v]');
if (opt && !opt.hidden) this._pick(type, parseInt(opt.dataset.v));
});
if (sinput) {
sinput.addEventListener('input', () => this._filterYear(list, sinput.value));
sinput.addEventListener('keydown', e => {
if (e.key === 'Escape') this._closeAll();
});
}
}
_pick(type, val) {
const field = this.f[type];
const valEl = field.querySelector('.dp-val');
const list = field.querySelector('[data-dp-list]');
const MONTHS = ['January','February','March','April','May','June',
'July','August','September','October','November','December'];
this.v[type] = val;
valEl.textContent = type === 'month' ? MONTHS[val - 1] : String(val);
valEl.classList.remove('ph');
list.querySelectorAll('[data-v]').forEach(o =>
o.setAttribute('aria-selected', parseInt(o.dataset.v) === val ? 'true' : 'false')
);
this._closeAll();
if (type === 'month' || type === 'year') this._constrainDays();
this._flush();
}
_constrainDays() {
const m = this.v.month || 1;
const y = this.v.year || 2000;
const max = new Date(y, m, 0).getDate(); // last day of month
const list = this.f.day.querySelector('[data-dp-list]');
list.querySelectorAll('[data-v]').forEach(o => {
const d = parseInt(o.dataset.v);
o.hidden = d > max;
if (d > max && this.v.day === d) {
this.v.day = null;
const valEl = this.f.day.querySelector('.dp-val');
valEl.textContent = 'Day';
valEl.classList.add('ph');
o.setAttribute('aria-selected', 'false');
}
});
}
_filterYear(list, q) {
list.querySelectorAll('[data-v]').forEach(o => {
o.style.display = (q.length > 0 && !o.dataset.v.startsWith(q)) ? 'none' : '';
});
}
_closeAll() {
this.root.querySelectorAll('.dp-panel').forEach(p => { p.hidden = true; });
this.root.querySelectorAll('.dp-btn').forEach(b => b.setAttribute('aria-expanded', 'false'));
}
_flush() {
const { day, month, year } = this.v;
if (day && month && year) {
this.hidden.value =
`${year}-${String(month).padStart(2,'0')}-${String(day).padStart(2,'0')}`;
} else {
this.hidden.value = '';
}
this.hidden.dispatchEvent(new Event('change', { bubbles: true }));
}
};
}());
</script>
@endonce
{{-- ── Component HTML ─────────────────────────────────────────────────── --}}
<div class="dp-wrap {{ $class }}" id="{{ $uid }}" @if($style) style="{{ $style }}" @endif>
@if($label)
<label class="dp-lbl" for="{{ $inputId }}">
{{ $label }}@if($required)<span class="req">*</span>@endif
</label>
@endif
<div class="dp-row">
{{-- ── Day ──────────────────────────────────────────────────────── --}}
<div class="dp-field dp-day">
<button type="button" class="dp-btn" aria-expanded="false" aria-label="Day">
<span class="dp-val{{ !$initDay ? ' ph' : '' }}">{{ $initDay ?? 'Day' }}</span>
<i class="bi bi-chevron-down dp-arr"></i>
</button>
<div class="dp-panel" hidden>
<ul class="dp-list dp-day-grid" data-dp-list>
@for($d = 1; $d <= 31; $d++)
<li class="dp-day-opt"
data-v="{{ $d }}"
aria-selected="{{ $initDay === $d ? 'true' : 'false' }}"
role="option">{{ $d }}</li>
@endfor
</ul>
</div>
</div>
{{-- ── Month ─────────────────────────────────────────────────────── --}}
<div class="dp-field dp-month">
<button type="button" class="dp-btn" aria-expanded="false" aria-label="Month">
<span class="dp-val{{ !$initMonth ? ' ph' : '' }}">
{{ $initMonth ? $months[$initMonth - 1] : 'Month' }}
</span>
<i class="bi bi-chevron-down dp-arr"></i>
</button>
<div class="dp-panel" hidden>
<ul class="dp-list" data-dp-list>
@foreach($months as $mi => $mname)
<li class="dp-list-opt"
data-v="{{ $mi + 1 }}"
aria-selected="{{ $initMonth === ($mi + 1) ? 'true' : 'false' }}"
role="option">{{ $mname }}</li>
@endforeach
</ul>
</div>
</div>
{{-- ── Year ──────────────────────────────────────────────────────── --}}
<div class="dp-field dp-year">
<button type="button" class="dp-btn" aria-expanded="false" aria-label="Year">
<span class="dp-val{{ !$initYear ? ' ph' : '' }}">{{ $initYear ?? 'Year' }}</span>
<i class="bi bi-chevron-down dp-arr"></i>
</button>
<div class="dp-panel" hidden>
<div class="dp-srch">
<i class="bi bi-search"></i>
<input type="text" class="dp-sinput" placeholder="Year…" autocomplete="off" spellcheck="false">
</div>
<ul class="dp-list" data-dp-list>
@for($y = $maxYear; $y >= $minYear; $y--)
<li class="dp-list-opt"
data-v="{{ $y }}"
aria-selected="{{ $initYear === $y ? 'true' : 'false' }}"
role="option">{{ $y }}</li>
@endfor
</ul>
</div>
</div>
</div>{{-- .dp-row --}}
<input type="hidden"
name="{{ $name }}"
id="{{ $inputId }}"
value="{{ $value }}"
@if($required) required @endif>
</div>{{-- .dp-wrap --}}
<script>
(function () {
function boot() { new DatePicker('{{ $uid }}'); }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}());
</script>
@@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{ $subject ?? config('app.name') }}</title>
<style>
/* Reset */
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
table, td { mso-table-lspace: 0; mso-table-rspace: 0; }
img { -ms-interpolation-mode: bicubic; border: 0; outline: none; text-decoration: none; }
body { margin: 0; padding: 0; background-color: #0f0f0f; width: 100% !important; }
.wrapper { background-color: #0f0f0f; padding: 40px 16px; }
.email-card {
max-width: 600px;
margin: 0 auto;
background: #181818;
border-radius: 16px;
overflow: hidden;
border: 1px solid #2a2a2a;
box-shadow: 0 24px 64px rgba(0,0,0,0.6);
}
/* Header */
.email-header {
background: #0f0f0f;
border-bottom: 1px solid #2a2a2a;
padding: 28px 40px;
text-align: center;
}
.email-header img { height: 36px; display: block; margin: 0 auto; }
/* Body */
.email-body { padding: 40px; }
.email-icon {
width: 64px;
height: 64px;
border-radius: 50%;
margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
}
h1.email-title {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 22px;
font-weight: 700;
color: #f1f1f1;
margin: 0 0 8px;
text-align: center;
line-height: 1.3;
}
p.email-subtitle {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
color: #888;
text-align: center;
margin: 0 0 32px;
line-height: 1.6;
}
p.email-text {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
color: #c8c8c8;
margin: 0 0 20px;
line-height: 1.7;
}
/* CTA Button */
.email-btn-wrap { text-align: center; margin: 32px 0; }
.email-btn {
display: inline-block;
background: #e61e1e;
color: #ffffff !important;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
font-weight: 700;
text-decoration: none;
padding: 14px 36px;
border-radius: 50px;
letter-spacing: 0.3px;
box-shadow: 0 8px 24px rgba(230,30,30,0.35);
}
.email-btn:hover { background: #c91a1a; }
/* Info box */
.email-infobox {
background: #222;
border: 1px solid #2d2d2d;
border-radius: 12px;
padding: 20px 24px;
margin: 24px 0;
}
.email-infobox-label {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #666;
margin: 0 0 12px;
}
.email-inforow {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 10px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.email-inforow:last-child { margin-bottom: 0; }
.email-inforow-key {
font-size: 13px;
color: #666;
min-width: 90px;
flex-shrink: 0;
}
.email-inforow-val {
font-size: 13px;
color: #c8c8c8;
font-weight: 500;
word-break: break-all;
}
/* Divider */
.email-divider {
border: none;
border-top: 1px solid #2a2a2a;
margin: 32px 0;
}
/* Small note */
.email-note {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
color: #555;
text-align: center;
line-height: 1.7;
margin: 0;
}
.email-note a { color: #888; text-decoration: underline; }
/* Footer */
.email-footer {
background: #0f0f0f;
border-top: 1px solid #2a2a2a;
padding: 24px 40px;
text-align: center;
}
.email-footer p {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
color: #444;
margin: 4px 0;
line-height: 1.6;
}
.email-footer a { color: #666; text-decoration: none; }
.email-footer a:hover { color: #e61e1e; }
/* Thumbnail */
.email-thumb-wrap { margin: 0 0 28px; text-align: center; }
.email-thumb-wrap a { display: block; }
.email-thumb-wrap img {
width: 100%;
max-width: 520px;
height: auto;
border-radius: 12px;
display: block;
margin: 0 auto;
border: 1px solid #2a2a2a;
}
.email-thumb-placeholder {
width: 100%;
max-width: 520px;
height: 190px;
background: linear-gradient(135deg, #1a1a1a 0%, #2a0808 100%);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
border: 1px solid #2a2a2a;
flex-direction: column;
gap: 10px;
}
.email-thumb-placeholder span {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
font-weight: 600;
color: #888;
}
@media only screen and (max-width: 600px) {
.wrapper { padding: 20px 10px; }
.email-body { padding: 28px 24px; }
.email-header { padding: 22px 24px; }
.email-footer { padding: 20px 24px; }
h1.email-title { font-size: 20px; }
}
</style>
</head>
<body>
<div class="wrapper">
<div class="email-card">
{{-- Header --}}
<div class="email-header">
<img src="{{ config('app.url') }}/storage/images/fullLogo.png" alt="{{ config('app.name') }}">
</div>
{{-- Body --}}
<div class="email-body">
{{ $slot }}
</div>
{{-- Footer --}}
<div class="email-footer">
<p>&copy; {{ date('Y') }} <strong style="color:#666;">TAKEONE</strong> &mdash; Video Platform</p>
<p><a href="{{ config('app.url') }}">{{ config('app.url') }}</a></p>
<p style="margin-top:8px;">This email was sent to you because you have an account on TAKEONE.</p>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,172 @@
@props([
'name' => 'gender',
'id' => null,
'value' => null,
'label' => null,
'required' => false,
'class' => '',
'style' => '',
])
@php
$uid = 'gsd_' . ($id ?? $name) . '_' . substr(md5(uniqid()), 0, 8);
$inputId = $id ?? $name;
$options = [
['value' => 'male', 'symbol' => '♂', 'label' => 'Male', 'color' => '#4a9eff'],
['value' => 'female', 'symbol' => '♀', 'label' => 'Female', 'color' => '#ff6eb0'],
];
$selected = null;
$placeholder = 'Select gender';
foreach ($options as $opt) {
if ($opt['value'] === $value) { $selected = $opt; break; }
}
$isPlaceholder = !$selected;
@endphp
@once('gsd-styles')
<style>
.gsd-wrap { position: relative; }
.gsd-lbl { display: block; margin-bottom: 6px; font-weight: 500; font-size: 14px; color: var(--text-primary, #f1f1f1); }
.gsd-lbl .req { color: var(--brand-red, #e61e1e); margin-left: 2px; }
.gsd-btn {
display: flex; align-items: center; gap: 10px; width: 100%;
background: var(--bg-dark, #0f0f0f); border: 1px solid var(--border-color, #303030);
border-radius: 8px; padding: 10px 14px; color: var(--text-primary, #f1f1f1);
font-size: 14px; font-family: inherit; cursor: pointer; text-align: left;
transition: border-color .2s; outline: none; min-height: 46px; box-sizing: border-box;
}
.gsd-btn:hover, .gsd-btn:focus-visible { border-color: var(--brand-red, #e61e1e); }
.gsd-btn[aria-expanded="true"] { border-color: var(--brand-red, #e61e1e); }
.gsd-sym { font-size: 20px; line-height: 1; flex-shrink: 0; user-select: none; }
.gsd-val { flex: 1; }
.gsd-val.ph { color: var(--text-secondary, #aaa); }
.gsd-arr { flex-shrink: 0; font-size: 11px; color: var(--text-secondary, #aaa); margin-left: auto; transition: transform .2s; }
.gsd-btn[aria-expanded="true"] .gsd-arr { transform: rotate(180deg); }
.gsd-panel {
position: fixed; z-index: 1060; min-width: 180px;
background: var(--bg-secondary, #1e1e1e); border: 1px solid var(--border-color, #303030);
border-radius: 10px; box-shadow: 0 12px 32px rgba(0,0,0,.55); overflow: hidden;
padding: 4px 0;
}
.gsd-opt {
display: flex; align-items: center; gap: 12px; padding: 10px 14px;
cursor: pointer; font-size: 14px; color: var(--text-primary, #f1f1f1);
transition: background .1s; outline: none; border: none; background: none;
width: 100%; text-align: left; font-family: inherit;
}
.gsd-opt:hover, .gsd-opt:focus { background: rgba(255,255,255,.07); }
.gsd-opt[aria-selected="true"] { background: rgba(255,255,255,.05); }
.gsd-opt-sym { font-size: 22px; line-height: 1; user-select: none; }
.gsd-opt-lbl { font-weight: 500; }
</style>
@endonce
@if($label)
<div class="gsd-wrap {{ $class }}" id="{{ $uid }}" @if($style) style="{{ $style }}" @endif>
<label class="gsd-lbl" for="{{ $inputId }}">
{{ $label }}@if($required)<span class="req">*</span>@endif
</label>
@else
<div class="gsd-wrap {{ $class }}" id="{{ $uid }}" @if($style) style="{{ $style }}" @endif>
@endif
<button type="button"
class="gsd-btn"
aria-haspopup="listbox"
aria-expanded="false"
aria-label="{{ $label ?? 'Select gender' }}">
@if($selected)
<span class="gsd-sym" style="color:{{ $selected['color'] }}">{{ $selected['symbol'] }}</span>
<span class="gsd-val">{{ $selected['label'] }}</span>
@else
<span class="gsd-sym" style="color:var(--text-secondary,#aaa)">⚧</span>
<span class="gsd-val ph">{{ $placeholder }}</span>
@endif
<i class="bi bi-chevron-down gsd-arr"></i>
</button>
<div class="gsd-panel" hidden role="listbox">
@foreach($options as $opt)
<button type="button"
class="gsd-opt"
role="option"
data-v="{{ $opt['value'] }}"
aria-selected="{{ $value === $opt['value'] ? 'true' : 'false' }}">
<span class="gsd-opt-sym" style="color:{{ $opt['color'] }}">{{ $opt['symbol'] }}</span>
<span class="gsd-opt-lbl">{{ $opt['label'] }}</span>
</button>
@endforeach
</div>
<input type="hidden"
name="{{ $name }}"
id="{{ $inputId }}"
value="{{ $value }}"
@if($required) required @endif>
</div>
<script>
(function () {
var uid = '{{ $uid }}';
function boot() {
var root = document.getElementById(uid);
var btn = root.querySelector('.gsd-btn');
var panel = root.querySelector('.gsd-panel');
var hidden = root.querySelector('input[type=hidden]');
var symEl = btn.querySelector('.gsd-sym');
var valEl = btn.querySelector('.gsd-val');
function open() {
document.querySelectorAll('.gsd-panel:not([hidden])').forEach(function(p) { p.hidden = true; });
document.querySelectorAll('.gsd-btn[aria-expanded=true]').forEach(function(b) { b.setAttribute('aria-expanded','false'); });
panel.hidden = false;
btn.setAttribute('aria-expanded', 'true');
var r = btn.getBoundingClientRect();
var goUp = window.innerHeight - r.bottom < 120 && r.top > 120;
panel.style.left = r.left + 'px';
panel.style.width = Math.max(r.width, 180) + 'px';
if (goUp) {
panel.style.top = '';
panel.style.bottom = (window.innerHeight - r.top + 4) + 'px';
} else {
panel.style.top = (r.bottom + 4) + 'px';
panel.style.bottom = '';
}
}
function close() { panel.hidden = true; btn.setAttribute('aria-expanded', 'false'); }
function pick(opt) {
hidden.value = opt.dataset.v;
var sym = opt.querySelector('.gsd-opt-sym');
var lbl = opt.querySelector('.gsd-opt-lbl');
symEl.textContent = sym.textContent;
symEl.style.color = sym.style.color;
valEl.textContent = lbl.textContent;
valEl.classList.remove('ph');
panel.querySelectorAll('.gsd-opt').forEach(function(o) {
o.setAttribute('aria-selected', o === opt ? 'true' : 'false');
});
close(); btn.focus();
hidden.dispatchEvent(new Event('change', { bubbles: true }));
}
btn.addEventListener('click', function(e) { e.stopPropagation(); panel.hidden ? open() : close(); });
btn.addEventListener('keydown', function(e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); panel.hidden ? open() : close(); } });
panel.addEventListener('mousedown', function(e) { e.stopPropagation(); });
panel.querySelectorAll('.gsd-opt').forEach(function(opt) {
opt.addEventListener('click', function() { pick(opt); });
opt.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(opt); }
if (e.key === 'Escape') { close(); btn.focus(); }
});
});
document.addEventListener('mousedown', function(e) { if (!root.contains(e.target)) close(); });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}());
</script>
@@ -0,0 +1,406 @@
@php
$id = $attributes->get('id', 'cropper_' . Str::random(6));
$width = (int) $attributes->get('width', 300);
$height = (int) $attributes->get('height', 300);
$shape = $attributes->get('shape', 'circle'); // 'circle' or 'square'
$folder = $attributes->get('folder', 'uploads');
$filename = $attributes->get('filename', 'image_' . time());
$callback = $attributes->get('callback', ''); // JS function called with (url) after server upload
$updateUrl = $attributes->get('update-url', ''); // POST {path} here after server upload
$title = $attributes->get('title', $shape === 'circle' ? 'Change Photo' : 'Crop Image');
$outputWidth = (int) $attributes->get('output-width', 0); // final output px width (0 = viewport size)
$targetInput = $attributes->get('target-input', ''); // form mode: ID of file input to set result on
$previewImg = $attributes->get('preview-img', ''); // ID of img to update with preview
@endphp
@once
<link rel="stylesheet" href="{{ asset('css/cropme.min.css') }}">
<script src="{{ asset('js/cropme.min.js') }}"></script>
<style>
/* ── TakeOne Cropper Modal ─────────────────────────── */
.tc-overlay {
display: none; position: fixed; inset: 0; z-index: 10100;
background: rgba(0,0,0,.82); backdrop-filter: blur(4px);
align-items: center; justify-content: center;
}
.tc-overlay.open { display: flex; animation: tcFadeIn .18s ease; }
@keyframes tcFadeIn { from { opacity: 0; } to { opacity: 1; } }
.tc-modal {
background: #141414; border: 1px solid rgba(255,255,255,.12);
border-radius: 18px; width: min(540px, 95vw);
box-shadow: 0 24px 80px rgba(0,0,0,.75);
overflow: hidden; animation: tcSlideUp .2s cubic-bezier(.34,1.3,.64,1);
}
@keyframes tcSlideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.tc-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px 14px;
border-bottom: 1px solid rgba(255,255,255,.07);
}
.tc-modal-title {
font-size: 15px; font-weight: 700; color: #fff;
display: flex; align-items: center; gap: 8px;
}
.tc-modal-title i { color: #ef4444; }
.tc-modal-close {
background: none; border: none; color: rgba(255,255,255,.45);
font-size: 20px; cursor: pointer; line-height: 1; padding: 4px 6px;
border-radius: 6px; transition: color .15s, background .15s;
}
.tc-modal-close:hover { color: #fff; background: rgba(255,255,255,.08); }
.tc-modal-body { padding: 16px 20px; }
.tc-file-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.tc-file-label {
display: inline-flex; align-items: center; gap: 7px; flex-shrink: 0;
height: 36px; padding: 0 14px; border-radius: 8px;
background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.12);
color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
transition: background .15s;
}
.tc-file-label:hover { background: rgba(255,255,255,.13); }
.tc-file-name {
font-size: 12px; color: rgba(255,255,255,.38); flex: 1;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tc-canvas {
width: 100%; height: 320px; background: #0d0d0d;
border-radius: 10px; border: 1px solid rgba(255,255,255,.07);
overflow: hidden; position: relative;
}
.tc-placeholder {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center;
color: rgba(255,255,255,.2); gap: 10px; pointer-events: none;
}
.tc-placeholder i { font-size: 42px; }
.tc-placeholder span { font-size: 13px; }
.tc-controls { display: flex; gap: 14px; margin-top: 14px; }
.tc-control { flex: 1; }
.tc-control-label {
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
color: rgba(255,255,255,.35); margin-bottom: 6px; display: flex; align-items: center; gap: 5px;
}
.tc-range {
-webkit-appearance: none; appearance: none; width: 100%; height: 3px;
background: rgba(255,255,255,.12); border-radius: 3px; outline: none; cursor: pointer;
}
.tc-range::-webkit-slider-thumb {
-webkit-appearance: none; width: 15px; height: 15px;
border-radius: 50%; background: #ef4444; cursor: pointer;
box-shadow: 0 0 0 3px rgba(239,68,68,.2); transition: box-shadow .15s;
}
.tc-range::-webkit-slider-thumb:hover { box-shadow: 0 0 0 5px rgba(239,68,68,.3); }
.tc-range::-moz-range-thumb {
width: 15px; height: 15px; border: none;
border-radius: 50%; background: #ef4444; cursor: pointer;
}
.tc-modal-footer {
padding: 12px 20px 18px; display: flex; gap: 8px; justify-content: flex-end;
border-top: 1px solid rgba(255,255,255,.07); flex-wrap: wrap;
}
.tc-btn {
display: inline-flex; align-items: center; gap: 7px;
height: 38px; padding: 0 18px; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer; border: none; font-family: inherit;
transition: background .15s, transform .1s, opacity .15s;
}
.tc-btn-ghost {
background: rgba(255,255,255,.07); color: rgba(255,255,255,.75);
border: 1px solid rgba(255,255,255,.12);
}
.tc-btn-ghost:hover { background: rgba(255,255,255,.13); color: #fff; }
.tc-btn-as-is {
background: rgba(255,255,255,.05); color: rgba(255,255,255,.55);
border: 1px solid rgba(255,255,255,.09); margin-right: auto;
}
.tc-btn-as-is:hover { background: rgba(255,255,255,.1); color: rgba(255,255,255,.85); }
.tc-btn-as-is:disabled { opacity: .3; cursor: not-allowed; }
.tc-btn-primary { background: #ef4444; color: #fff; }
.tc-btn-primary:hover:not(:disabled) { background: #dc2626; transform: translateY(-1px); }
.tc-btn-primary:disabled { opacity: .45; cursor: not-allowed; }
</style>
@endonce
{{-- Modal --}}
<div class="tc-overlay" id="tcOverlay_{{ $id }}" role="dialog" aria-modal="true">
<div class="tc-modal">
<div class="tc-modal-header">
<span class="tc-modal-title">
<i class="bi bi-crop"></i>
{{ $title }}
</span>
<button class="tc-modal-close" onclick="closeCropperModal('{{ $id }}')" aria-label="Close">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="tc-modal-body">
<div class="tc-file-row">
<label class="tc-file-label" for="tcInput_{{ $id }}">
<i class="bi bi-upload"></i>
Choose image
</label>
<span class="tc-file-name" id="tcFileName_{{ $id }}">No file chosen</span>
<input type="file" id="tcInput_{{ $id }}" accept="image/*" style="display:none" aria-label="Select image file">
</div>
<div class="tc-canvas" id="tcCanvas_{{ $id }}">
<div class="tc-placeholder" id="tcPlaceholder_{{ $id }}">
<i class="bi bi-image"></i>
<span>Upload an image to start cropping</span>
</div>
</div>
<div class="tc-controls">
<div class="tc-control">
<label class="tc-control-label" for="tcZoom_{{ $id }}">
<i class="bi bi-zoom-in"></i> Zoom
</label>
<input type="range" class="tc-range" id="tcZoom_{{ $id }}" min="0" max="100" step="1" value="0">
</div>
<div class="tc-control">
<label class="tc-control-label" for="tcRot_{{ $id }}">
<i class="bi bi-arrow-clockwise"></i> Rotate
</label>
<input type="range" class="tc-range" id="tcRot_{{ $id }}" min="-180" max="180" step="1" value="0">
</div>
</div>
</div>
<div class="tc-modal-footer">
<button class="tc-btn tc-btn-as-is" id="tcAsIsBtn_{{ $id }}" disabled
onclick="tcUploadAsIs_{{ $id }}()">
<i class="bi bi-image"></i>
Upload as-is
</button>
<button class="tc-btn tc-btn-ghost" onclick="closeCropperModal('{{ $id }}')">
Cancel
</button>
<button class="tc-btn tc-btn-primary" id="tcSaveBtn_{{ $id }}" disabled
onclick="tcSave_{{ $id }}()">
<i class="bi bi-check-lg"></i>
<span id="tcSaveBtnText_{{ $id }}">Crop & Save</span>
</button>
</div>
</div>
</div>
<script>
(function () {
var cropperInst = null;
var originalFile = null;
var _intercept = false; // flag: we are programmatically setting the file, skip interceptor
var zoomMin = 0.01, zoomMax = 3;
var id = '{{ $id }}';
var vw = {{ $width }};
var vh = {{ $height }};
var shape = '{{ $shape }}';
var folder = '{{ $folder }}';
var filename = '{{ $filename }}';
var uploadUrl = '{{ route('image.upload') }}';
var updateUrl = '{{ $updateUrl }}';
var callbackFn = '{{ $callback }}';
var outputWidth = {{ $outputWidth > 0 ? $outputWidth : 0 }};
var targetInputId = '{{ $targetInput }}'; // form mode: ID of the file input to intercept
var previewImgId = '{{ $previewImg }}';
var isFormMode = targetInputId !== '';
function getCsrf() {
var m = document.querySelector('meta[name="csrf-token"]');
return m ? m.content : '';
}
/* ── Open / close ── */
function openModal() {
document.getElementById('tcOverlay_' + id).classList.add('open');
document.body.style.overflow = 'hidden';
}
window['openCropperModal_' + id] = openModal;
window.closeCropperModal = window.closeCropperModal || function (i) {
var el = document.getElementById('tcOverlay_' + i);
if (el) { el.classList.remove('open'); document.body.style.overflow = ''; }
};
document.getElementById('tcOverlay_' + id).addEventListener('click', function (e) {
if (e.target === this) window.closeCropperModal(id);
});
/* ── Preload a File into the cropper ── */
function preloadFile(file) {
if (!file) return;
originalFile = file;
document.getElementById('tcFileName_' + id).textContent = file.name;
var reader = new FileReader();
reader.onload = function (e) { initCropper(e.target.result); };
reader.readAsDataURL(file);
}
window['tcPreload_' + id] = preloadFile;
function initCropper(dataUrl) {
document.getElementById('tcPlaceholder_' + id).style.display = 'none';
document.getElementById('tcSaveBtn_' + id).disabled = false;
document.getElementById('tcAsIsBtn_' + id).disabled = false;
var canvas = document.getElementById('tcCanvas_' + id);
if (cropperInst) { cropperInst.destroy(); cropperInst = null; }
cropperInst = new Cropme(canvas, {
container: { width: '100%', height: 320 },
viewport: {
width: vw, height: vh,
type: shape,
border: { enable: true, width: 2, color: '#ef4444' }
},
transformOrigin: 'viewport',
zoom: { min: zoomMin, max: zoomMax, enable: true, mouseWheel: true, slider: false },
rotation: { enable: true, slider: false }
});
cropperInst.bind({ url: dataUrl }).then(function () {
document.getElementById('tcZoom_' + id).value = 0;
document.getElementById('tcRot_' + id).value = 0;
});
}
/* ── Internal file input (the "Choose image" button inside the modal) ── */
document.getElementById('tcInput_' + id).addEventListener('change', function () {
if (this.files && this.files[0]) preloadFile(this.files[0]);
});
/* ── Form-mode: expose open helper for external callers ── */
// Callers should invoke window['openCropperModal_' + id]() directly from their
// dropzone click/drop handlers rather than relying on event interception.
/* ── Zoom / rotate sliders ── */
document.getElementById('tcZoom_' + id).addEventListener('input', function () {
if (!cropperInst || !cropperInst.properties.image) return;
var p = parseFloat(this.value) / 100;
cropperInst.properties.scale = zoomMin + (zoomMax - zoomMin) * p;
var s = cropperInst.properties;
s.image.style.transform = 'translate3d(' + s.x + 'px,' + s.y + 'px,0) scale(' + s.scale + ') rotate(' + s.deg + 'deg)';
});
document.getElementById('tcRot_' + id).addEventListener('input', function () {
if (cropperInst) cropperInst.rotate(parseInt(this.value, 10));
});
/* ── Helpers ── */
function base64ToFile(base64, name) {
var arr = base64.split(',');
var mime = (arr[0].match(/:(.*?);/) || [])[1] || 'image/png';
var bin = atob(arr[1]);
var u8 = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
return new File([u8], name || 'cropped.png', { type: mime });
}
function setOnTargetInput(file) {
var el = document.getElementById(targetInputId);
if (!el) return;
try {
var dt = new DataTransfer();
dt.items.add(file);
_intercept = true;
el.files = dt.files;
el.dispatchEvent(new Event('change', { bubbles: true }));
} finally {
_intercept = false;
}
if (previewImgId) {
var prev = document.getElementById(previewImgId);
if (prev) prev.src = URL.createObjectURL(file);
}
}
function uploadToServer(base64, onDone, onFail) {
fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': getCsrf() },
body: JSON.stringify({ image: base64, folder: folder, filename: filename })
})
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res.success) throw new Error(res.message || 'Upload failed');
if (updateUrl) {
return fetch(updateUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': getCsrf() },
body: JSON.stringify({ path: res.path })
}).then(function () { return res; });
}
return res;
})
.then(onDone)
.catch(onFail);
}
/* ── Crop & Save ── */
window['tcSave_' + id] = function () {
if (!cropperInst) return;
var btn = document.getElementById('tcSaveBtn_' + id);
var txt = document.getElementById('tcSaveBtnText_' + id);
btn.disabled = true;
txt.textContent = 'Saving…';
var cropOpts = outputWidth > 0 ? { type: 'base64', width: outputWidth } : { type: 'base64' };
cropperInst.crop(cropOpts).then(function (base64) {
if (isFormMode) {
var fname = originalFile ? originalFile.name : 'cropped.png';
setOnTargetInput(base64ToFile(base64, fname));
window.closeCropperModal(id);
if (typeof window.showToast === 'function') window.showToast('Image ready!', 'success');
btn.disabled = false;
txt.textContent = 'Crop & Save';
} else {
uploadToServer(base64, function (res) {
window.closeCropperModal(id);
if (typeof window.showToast === 'function') window.showToast('Saved!', 'success');
if (callbackFn && typeof window[callbackFn] === 'function') window[callbackFn](res.url);
btn.disabled = false;
txt.textContent = 'Crop & Save';
}, function (err) {
if (typeof window.showToast === 'function') window.showToast(err.message || 'Upload failed', 'error');
btn.disabled = false;
txt.textContent = 'Crop & Save';
});
}
});
};
/* ── Upload as-is ── */
window['tcUploadAsIs_' + id] = function () {
if (!originalFile) { window.closeCropperModal(id); return; }
if (isFormMode) {
// Put the original (un-cropped) file on the target input so the form sees it.
setOnTargetInput(originalFile);
window.closeCropperModal(id);
} else {
// Server mode: read original file as base64 and upload unchanged
var btn = document.getElementById('tcSaveBtn_' + id);
var txt = document.getElementById('tcSaveBtnText_' + id);
var aisBtn = document.getElementById('tcAsIsBtn_' + id);
btn.disabled = true;
aisBtn.disabled = true;
txt.textContent = 'Uploading…';
var reader = new FileReader();
reader.onload = function (e) {
uploadToServer(e.target.result, function (res) {
window.closeCropperModal(id);
if (typeof window.showToast === 'function') window.showToast('Saved!', 'success');
if (callbackFn && typeof window[callbackFn] === 'function') window[callbackFn](res.url);
btn.disabled = false;
aisBtn.disabled = false;
txt.textContent = 'Crop & Save';
}, function (err) {
if (typeof window.showToast === 'function') window.showToast(err.message || 'Upload failed', 'error');
btn.disabled = false;
aisBtn.disabled = false;
txt.textContent = 'Crop & Save';
});
};
reader.readAsDataURL(originalFile);
}
};
})();
</script>
@@ -0,0 +1,307 @@
@props([
'name' => '',
'id' => null,
'value' => null,
'label' => null,
'placeholder' => 'Code',
'required' => false,
'class' => '',
'style' => '',
])
@php
use App\Data\Countries;
$options = Countries::forPhoneCode();
$uid = 'csd_' . ($id ?? $name) . '_' . substr(md5(uniqid()), 0, 8);
$inputId = $id ?? $name;
$selFlag = '🌐';
$selLabel = $placeholder;
$selSub = '';
if ($value) {
foreach ($options as $opt) {
if ($opt['value'] === $value || str_starts_with($opt['value'], $value . '|')) {
$selFlag = $opt['flag'];
$selLabel = $opt['label'];
$selSub = $opt['secondary'];
break;
}
}
}
$isPlaceholder = !$value;
@endphp
{{-- ── Shared CSS (output once across all three CSD components) ──────── --}}
@once('csd-styles')
<style>
.csd-wrap { position: relative; }
.csd-lbl { display: block; margin-bottom: 6px; font-weight: 500; font-size: 14px; color: var(--text-primary, #f1f1f1); }
.csd-lbl .req { color: var(--brand-red, #e61e1e); margin-left: 2px; }
.csd-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
background: var(--bg-dark, #0f0f0f);
border: 1px solid var(--border-color, #303030);
border-radius: 8px;
padding: 10px 14px;
color: var(--text-primary, #f1f1f1);
font-size: 14px;
font-family: inherit;
line-height: 1.4;
cursor: pointer;
text-align: left;
transition: border-color .2s;
outline: none;
min-height: 46px;
box-sizing: border-box;
}
.csd-btn:hover,
.csd-btn:focus-visible { border-color: var(--brand-red, #e61e1e); }
.csd-btn[aria-expanded="true"] { border-color: var(--brand-red, #e61e1e); }
.csd-ico { font-size: 18px; line-height: 1; flex-shrink: 0; user-select: none; }
.csd-val { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.csd-val.ph { color: var(--text-secondary, #aaa); }
.csd-sub { font-size: 12px; color: var(--text-secondary, #aaa); white-space: nowrap; flex-shrink: 0; }
.csd-arr { flex-shrink: 0; font-size: 11px; color: var(--text-secondary, #aaa); margin-left: 4px; transition: transform .2s; }
.csd-btn[aria-expanded="true"] .csd-arr { transform: rotate(180deg); }
.csd-panel {
position: fixed;
z-index: 1060;
min-width: 220px;
background: var(--bg-secondary, #1e1e1e);
border: 1px solid var(--border-color, #303030);
border-radius: 10px;
box-shadow: 0 12px 32px rgba(0,0,0,.55);
overflow: hidden;
}
.csd-srch {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color, #303030);
}
.csd-srch i { font-size: 13px; color: var(--text-secondary, #aaa); flex-shrink: 0; }
.csd-sinput {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--text-primary, #f1f1f1);
font-size: 13px;
font-family: inherit;
}
.csd-sinput::placeholder { color: var(--text-secondary, #aaa); }
.csd-list {
list-style: none;
margin: 0;
padding: 4px 0;
max-height: 240px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-color, #303030) transparent;
}
.csd-list::-webkit-scrollbar { width: 4px; }
.csd-list::-webkit-scrollbar-thumb { background: var(--border-color, #303030); border-radius: 2px; }
.csd-opt {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
color: var(--text-primary, #f1f1f1);
transition: background .1s;
outline: none;
}
.csd-opt:hover,
.csd-opt:focus { background: rgba(255,255,255,.07); }
.csd-opt[aria-selected="true"] { background: rgba(255,255,255,.05); }
.csd-opt-ico { font-size: 17px; line-height: 1; flex-shrink: 0; user-select: none; }
.csd-opt-main { font-weight: 500; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.csd-opt-sub { font-size: 12px; color: var(--text-secondary, #aaa); white-space: nowrap; flex-shrink: 0; max-width: 45%; overflow: hidden; text-overflow: ellipsis; }
.csd-empty { padding: 12px; font-size: 13px; color: var(--text-secondary, #aaa); text-align: center; }
</style>
@endonce
{{-- ── Shared JS (output once across all three CSD components) ───────── --}}
@once('csd-script')
<script>
(function () {
if (window.CSD) return;
window.CSD = class {
constructor(id) {
this.root = document.getElementById(id);
this.btn = this.root.querySelector('.csd-btn');
this.panel = this.root.querySelector('.csd-panel');
this.sinput = this.root.querySelector('.csd-sinput');
this.list = this.root.querySelector('.csd-list');
this.hidden = this.root.querySelector('input[type=hidden]');
this.icoEl = this.btn.querySelector('.csd-ico');
this.valEl = this.btn.querySelector('.csd-val');
this.subEl = this.btn.querySelector('.csd-sub');
this.emptyEl= this.root.querySelector('.csd-empty');
this._bind();
}
_bind() {
this.btn.addEventListener('click', e => { e.stopPropagation(); this.toggle(); });
this.sinput.addEventListener('input', () => this._filter());
this.list.addEventListener('click', e => {
const o = e.target.closest('.csd-opt');
if (o) this._pick(o);
});
document.addEventListener('click', e => { if (!this.root.contains(e.target)) this.close(); }, true);
this.btn.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.toggle(); }
});
this.sinput.addEventListener('keydown', e => {
if (e.key === 'Escape') { this.close(); this.btn.focus(); }
if (e.key === 'ArrowDown') { e.preventDefault(); this._move(0); }
if (e.key === 'Enter') { e.preventDefault(); }
});
this.list.addEventListener('keydown', e => {
const all = [...this.list.querySelectorAll('.csd-opt:not([hidden])')];
const i = all.indexOf(document.activeElement.closest('.csd-opt'));
if (e.key === 'ArrowDown') { e.preventDefault(); this._move(i + 1); }
if (e.key === 'ArrowUp') { e.preventDefault(); i > 0 ? this._move(i - 1) : this.sinput.focus(); }
if (e.key === 'Enter') { e.preventDefault(); if (all[i]) this._pick(all[i]); }
if (e.key === 'Escape') { this.close(); this.btn.focus(); }
});
}
toggle() { this.panel.hidden ? this.open() : this.close(); }
open() {
document.querySelectorAll('.csd-panel:not([hidden])').forEach(p => { p.hidden = true; });
document.querySelectorAll('.csd-btn[aria-expanded=true]').forEach(b => b.setAttribute('aria-expanded','false'));
this.panel.hidden = false;
this.btn.setAttribute('aria-expanded', 'true');
this.sinput.value = '';
this._filter();
requestAnimationFrame(() => this.sinput.focus());
const r = this.btn.getBoundingClientRect();
const goUp = window.innerHeight - r.bottom < 280 && r.top > 280;
this.panel.style.left = r.left + 'px';
this.panel.style.width = Math.max(r.width, 220) + 'px';
if (goUp) {
this.panel.style.top = '';
this.panel.style.bottom = (window.innerHeight - r.top + 4) + 'px';
} else {
this.panel.style.top = (r.bottom + 4) + 'px';
this.panel.style.bottom = '';
}
}
close() {
this.panel.hidden = true;
this.btn.setAttribute('aria-expanded', 'false');
}
_filter() {
const q = this.sinput.value.toLowerCase();
let n = 0;
this.list.querySelectorAll('.csd-opt').forEach(o => {
const show = o.dataset.s.includes(q);
o.style.display = show ? '' : 'none';
if (show) n++;
});
if (this.emptyEl) this.emptyEl.style.display = n > 0 ? 'none' : '';
}
_move(i) {
const all = [...this.list.querySelectorAll('.csd-opt:not([hidden])')];
if (all[i]) { all[i].tabIndex = 0; all[i].focus(); }
}
_pick(opt) {
this.hidden.value = opt.dataset.v;
this.icoEl.textContent = opt.querySelector('.csd-opt-ico').textContent;
this.valEl.textContent = opt.querySelector('.csd-opt-main').textContent;
this.valEl.classList.remove('ph');
if (this.subEl) {
const s = opt.querySelector('.csd-opt-sub');
this.subEl.textContent = s ? s.textContent : '';
}
this.list.querySelectorAll('.csd-opt').forEach(o =>
o.setAttribute('aria-selected', o === opt ? 'true' : 'false')
);
this.close();
this.btn.focus();
this.hidden.dispatchEvent(new Event('change', { bubbles: true }));
}
};
}());
</script>
@endonce
{{-- ── Component HTML ─────────────────────────────────────────────────── --}}
<div class="csd-wrap {{ $class }}" id="{{ $uid }}" @if($style) style="{{ $style }}" @endif>
@if($label)
<label class="csd-lbl" for="{{ $inputId }}">
{{ $label }}@if($required)<span class="req">*</span>@endif
</label>
@endif
<button type="button"
class="csd-btn"
aria-haspopup="listbox"
aria-expanded="false"
aria-label="{{ $label ?? 'Phone code' }}">
<span class="csd-ico">{{ $selFlag }}</span>
<span class="csd-val{{ $isPlaceholder ? ' ph' : '' }}">{{ $selLabel }}</span>
@if(!$isPlaceholder && $selSub)
<span class="csd-sub">{{ $selSub }}</span>
@endif
<i class="bi bi-chevron-down csd-arr"></i>
</button>
<div class="csd-panel" hidden role="listbox">
<div class="csd-srch">
<i class="bi bi-search"></i>
<input class="csd-sinput" type="text" placeholder="Search country or code…" autocomplete="off" spellcheck="false">
</div>
<ul class="csd-list">
@foreach($options as $opt)
<li class="csd-opt"
role="option"
tabindex="-1"
data-v="{{ $opt['value'] }}"
data-s="{{ $opt['search'] }}"
aria-selected="{{ $value && str_starts_with($opt['value'], $value.'|') ? 'true' : 'false' }}">
<span class="csd-opt-ico">{{ $opt['flag'] }}</span>
<span class="csd-opt-main">{{ $opt['label'] }}</span>
<span class="csd-opt-sub">{{ $opt['secondary'] }}</span>
</li>
@endforeach
</ul>
<p class="csd-empty" hidden>No results</p>
</div>
<input type="hidden"
name="{{ $name }}"
id="{{ $inputId }}"
value="{{ $value }}"
@if($required) required @endif>
</div>
<script>
(function () {
function boot() { new CSD('{{ $uid }}'); }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}());
</script>
@@ -0,0 +1,78 @@
@props(['playlist', 'showTypeBadge' => false])
@once
<style>
.pl-visibility-badge {
position: absolute;
bottom: 8px;
left: 8px;
background: rgba(0,0,0,.75);
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 3px 8px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 4px;
text-transform: uppercase;
letter-spacing: .4px;
pointer-events: none;
}
</style>
@endonce
@php $pl = $playlist; @endphp
<div class="yt-video-card">
<a href="{{ route('playlists.show', $pl->id) }}">
<div class="yt-video-thumb">
<img src="{{ $pl->thumbnail_url }}" alt="{{ $pl->name }}" loading="lazy">
<div class="pl-count-badge">
<i class="bi bi-collection-play-fill"></i>
{{ $pl->videos_count }}
</div>
@if($showTypeBadge)
<span class="feed-pl-type-badge">
<i class="bi bi-collection-play-fill"></i> PLAYLIST
</span>
@endif
@if($pl->visibility === 'private')
<span class="pl-visibility-badge"><i class="bi bi-lock-fill"></i> Private</span>
@elseif($pl->visibility === 'unlisted')
<span class="pl-visibility-badge"><i class="bi bi-link-45deg"></i> Unlisted</span>
@endif
</div>
</a>
<div class="yt-video-info">
<div class="yt-channel-icon"
style="cursor:pointer;"
onclick="window.location.href='{{ $pl->user ? route('channel', $pl->user->channel) : '#' }}'">
@if($pl->user?->avatar_url)
<img src="{{ $pl->user->avatar_url }}" alt="{{ $pl->user->name }}"
style="width:100%;height:100%;object-fit:cover;border-radius:50%;">
@elseif($pl->user)
<span style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700;color:#fff;">
{{ mb_strtoupper(mb_substr($pl->user->name, 0, 1)) }}
</span>
@endif
</div>
<div class="yt-video-details">
<h3 class="yt-video-title">
<a href="{{ route('playlists.show', $pl->id) }}">{{ $pl->name }}</a>
</h3>
@if($pl->user)
<a href="{{ route('channel', $pl->user->channel) }}" class="yt-channel-name"
onclick="event.stopPropagation()">{{ $pl->user->name }}</a>
@endif
<div class="yt-video-meta">
<span class="yt-type-label" style="color:#a78bfa;">
<i class="bi bi-collection-play-fill"></i>
Playlist
</span>
&nbsp;·&nbsp;
{{ number_format($pl->view_count) }} {{ Str::plural('view', $pl->view_count) }} · {{ $pl->created_at->diffForHumans() }}
</div>
</div>
</div>
</div>
@@ -0,0 +1,324 @@
@props([
'existing' => [], // [ ['platform'=>'twitter','value'=>'handle','visibility'=>'public'], ... ]
])
@php
$uid = 'sle_' . substr(md5(uniqid()), 0, 8);
@endphp
@once('sle-styles')
<style>
.sle-entry { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.sle-entry-input { flex: 1; min-width: 0; }
.sle-icon-btn {
flex-shrink: 0; display: flex; align-items: center; justify-content: center;
width: 38px; height: 38px; border-radius: 8px; background: transparent;
border: 1px solid var(--border-color, #303030);
font-size: 15px; cursor: pointer; transition: border-color .15s, color .15s, background .15s;
}
.sle-remove-btn { color: var(--text-secondary, #aaa); }
.sle-remove-btn:hover { border-color: #e61e1e; color: #e61e1e; background: rgba(230,30,30,.08); }
/* Visibility button colour per state */
.sle-vis-btn[data-vis="public"] { color: #22c55e; border-color: rgba(34,197,94,.35); }
.sle-vis-btn[data-vis="registered"] { color: #3b82f6; border-color: rgba(59,130,246,.35); }
.sle-vis-btn[data-vis="subscribers"] { color: #f59e0b; border-color: rgba(245,158,11,.35); }
.sle-vis-btn[data-vis="only_me"] { color: #6b7280; border-color: rgba(107,114,128,.35); }
.sle-empty {
text-align: center; padding: 28px 16px;
color: var(--text-secondary, #aaa); font-size: 14px;
}
.sle-empty i { font-size: 30px; display: block; margin-bottom: 8px; opacity: .45; }
/* Shared popup panel */
.sle-popup-wrap { position: relative; }
.sle-popup {
position: fixed; z-index: 2100;
background: var(--bg-secondary, #1e1e1e); border: 1px solid var(--border-color, #303030);
border-radius: 10px; box-shadow: 0 12px 32px rgba(0,0,0,.6); padding: 4px 0;
}
.sle-pop-opt {
display: flex; align-items: center; gap: 9px; padding: 8px 13px;
cursor: pointer; font-size: 13px; color: var(--text-primary, #f1f1f1);
transition: background .1s; white-space: nowrap;
}
.sle-pop-opt:hover { background: rgba(255,255,255,.07); }
.sle-pop-opt.active { font-weight: 600; }
.sle-pop-opt i { font-size: 14px; flex-shrink: 0; width: 16px; text-align: center; }
/* Platform picker panel is scrollable */
.sle-popup.plat-panel {
overflow-y: auto; max-height: 260px;
scrollbar-width: thin; scrollbar-color: var(--border-color, #303030) transparent;
}
.sle-popup.plat-panel::-webkit-scrollbar { width: 4px; }
.sle-popup.plat-panel::-webkit-scrollbar-thumb { background: var(--border-color, #303030); border-radius: 2px; }
/* Platform picker button */
.sle-sp-wrap { position: relative; flex: 0 0 160px; }
.sle-sp-btn {
display: flex; align-items: center; gap: 8px; width: 100%;
background: var(--bg-dark, #0f0f0f); border: 1px solid var(--border-color, #303030);
border-radius: 8px; padding: 9px 10px; color: var(--text-primary, #f1f1f1);
font-size: 13px; font-family: inherit; cursor: pointer; text-align: left;
transition: border-color .2s; outline: none; min-height: 38px; box-sizing: border-box;
}
.sle-sp-btn:hover, .sle-sp-btn:focus-visible { border-color: var(--brand-red, #e61e1e); }
.sle-sp-btn[aria-expanded="true"] { border-color: var(--brand-red, #e61e1e); }
.sle-sp-ico { font-size: 15px; flex-shrink: 0; }
.sle-sp-val { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sle-sp-val.ph { color: var(--text-secondary, #aaa); }
.sle-sp-arr { font-size: 10px; color: var(--text-secondary, #aaa); flex-shrink: 0; transition: transform .2s; }
.sle-sp-btn[aria-expanded="true"] .sle-sp-arr { transform: rotate(180deg); }
</style>
@endonce
@once('sle-script')
<script>
(function () {
if (window.SocialLinksEditor) return;
// Close all popups when clicking outside
document.addEventListener('click', e => {
if (!e.target.closest('.sle-popup-wrap') && !e.target.closest('.sle-sp-wrap')) {
document.querySelectorAll('.sle-popup').forEach(p => { p.hidden = true; });
document.querySelectorAll('[aria-expanded="true"].sle-sp-btn').forEach(b => b.setAttribute('aria-expanded','false'));
}
});
window.SocialLinksEditor = class {
constructor(uid, existing) {
this.root = document.getElementById(uid);
this.entriesEl = this.root.querySelector('[data-sle-entries]');
this.addBtn = this.root.querySelector('[data-sle-add]');
this._idx = 0;
this.PLATFORMS = [
{ key: 'twitter', label: 'Twitter / X', icon: 'bi-twitter-x', placeholder: 'username', type: 'text', defaultVis: 'public' },
{ key: 'instagram', label: 'Instagram', icon: 'bi-instagram', placeholder: 'username', type: 'text', defaultVis: 'public' },
{ key: 'facebook', label: 'Facebook', icon: 'bi-facebook', placeholder: 'username', type: 'text', defaultVis: 'public' },
{ key: 'youtube', label: 'YouTube', icon: 'bi-youtube', placeholder: 'channel', type: 'text', defaultVis: 'public' },
{ key: 'linkedin', label: 'LinkedIn', icon: 'bi-linkedin', placeholder: 'username', type: 'text', defaultVis: 'public' },
{ key: 'tiktok', label: 'TikTok', icon: 'bi-tiktok', placeholder: 'username', type: 'text', defaultVis: 'public' },
{ key: 'whatsapp', label: 'WhatsApp', icon: 'bi-whatsapp', placeholder: '97312345678 (no + or spaces)', type: 'text', defaultVis: 'subscribers' },
{ key: 'website', label: 'Website', icon: 'bi-globe', placeholder: 'https://example.com', type: 'url', defaultVis: 'public' },
{ key: 'google_location', label: 'Google Maps', icon: 'bi-geo-alt-fill', placeholder: 'https://maps.google.com/...', type: 'url', defaultVis: 'public' },
{ key: 'social_phone', label: 'Phone', icon: 'bi-telephone-fill', placeholder: '+97312345678', type: 'text', defaultVis: 'subscribers' },
{ key: 'social_email', label: 'Email', icon: 'bi-envelope-fill', placeholder: '[email protected]', type: 'email', defaultVis: 'subscribers' },
];
this.VISIBILITIES = [
{ key: 'public', label: 'Public', icon: 'bi-globe2', color: '#22c55e', hint: 'Everyone including guests' },
{ key: 'registered', label: 'Registered', icon: 'bi-person-check-fill', color: '#3b82f6', hint: 'Logged-in users only' },
{ key: 'subscribers', label: 'Subscribers', icon: 'bi-star-fill', color: '#f59e0b', hint: 'Your subscribers only' },
{ key: 'only_me', label: 'Only me', icon: 'bi-lock-fill', color: '#6b7280', hint: 'Hidden from everyone else' },
];
existing.forEach(({ platform, value, visibility }) => {
if (platform && value) {
this.entriesEl.appendChild(this._buildEntry(platform, value, visibility || 'public'));
}
});
this._renderEmpty();
this.addBtn.addEventListener('click', () => {
const row = this._buildEntry('', '', 'public');
this.entriesEl.appendChild(row);
this._renderEmpty();
row.querySelector('.sle-sp-btn').click();
});
}
_platform(key) { return this.PLATFORMS.find(p => p.key === key) || null; }
_visibility(key) { return this.VISIBILITIES.find(v => v.key === key) || this.VISIBILITIES[0]; }
_renderEmpty() {
let empty = this.entriesEl.querySelector('.sle-empty');
if (this.entriesEl.querySelectorAll('.sle-entry').length === 0) {
if (!empty) {
empty = document.createElement('div');
empty.className = 'sle-empty';
empty.innerHTML = '<i class="bi bi-share"></i>No social accounts added yet.';
this.entriesEl.appendChild(empty);
}
} else {
if (empty) empty.remove();
}
}
_closeAllPopups() {
document.querySelectorAll('.sle-popup').forEach(p => { p.hidden = true; });
document.querySelectorAll('[aria-expanded="true"].sle-sp-btn').forEach(b => b.setAttribute('aria-expanded','false'));
}
_togglePopup(trigger, panel) {
const wasOpen = !panel.hidden;
this._closeAllPopups();
if (!wasOpen) {
panel.hidden = false;
if (trigger.classList.contains('sle-sp-btn')) trigger.setAttribute('aria-expanded','true');
const r = trigger.getBoundingClientRect();
const isRight = panel.classList.contains('pop-right');
const goUp = window.innerHeight - r.bottom < 270 && r.top > 270;
panel.style.minWidth = r.width + 'px';
if (goUp) {
panel.style.top = '';
panel.style.bottom = (window.innerHeight - r.top + 4) + 'px';
} else {
panel.style.top = (r.bottom + 4) + 'px';
panel.style.bottom = '';
}
if (isRight) {
panel.style.left = '';
panel.style.right = (window.innerWidth - r.right) + 'px';
} else {
panel.style.left = r.left + 'px';
panel.style.right = '';
}
}
}
_buildEntry(key, value, visibility) {
const idx = this._idx++;
const p = this._platform(key);
const vis = this._visibility(visibility || (p ? p.defaultVis : 'public'));
const row = document.createElement('div');
row.className = 'sle-entry';
// ── 1. Platform picker ───────────────────────────────────────
const spWrap = document.createElement('div');
spWrap.className = 'sle-sp-wrap';
const spBtn = document.createElement('button');
spBtn.type = 'button'; spBtn.className = 'sle-sp-btn'; spBtn.setAttribute('aria-expanded','false');
const spIco = document.createElement('i');
spIco.className = p ? `sle-sp-ico bi ${p.icon}` : 'sle-sp-ico bi bi-plus-circle';
const spVal = document.createElement('span');
spVal.className = p ? 'sle-sp-val' : 'sle-sp-val ph';
spVal.textContent = p ? p.label : 'Platform';
const spArr = document.createElement('i');
spArr.className = 'bi bi-chevron-down sle-sp-arr';
spBtn.append(spIco, spVal, spArr);
const platInput = document.createElement('input');
platInput.type = 'hidden'; platInput.name = `slink[${idx}][platform]`; platInput.value = key || '';
const spPanel = document.createElement('div');
spPanel.className = 'sle-popup plat-panel'; spPanel.hidden = true;
this.PLATFORMS.forEach(pl => {
const opt = document.createElement('div');
opt.className = `sle-pop-opt${pl.key === key ? ' active' : ''}`;
opt.innerHTML = `<i class="bi ${pl.icon}"></i>${pl.label}`;
opt.addEventListener('click', () => {
spIco.className = `sle-sp-ico bi ${pl.icon}`;
spVal.textContent = pl.label; spVal.classList.remove('ph');
spPanel.querySelectorAll('.sle-pop-opt').forEach(o => o.classList.remove('active'));
opt.classList.add('active');
platInput.value = pl.key;
inp.type = pl.type;
inp.placeholder = pl.placeholder;
inp.disabled = false;
inp.value = '';
// Apply default visibility for this platform
const defVis = this._visibility(pl.defaultVis);
visInput.value = defVis.key;
visBtn.dataset.vis = defVis.key;
visBtn.innerHTML = `<i class="bi ${defVis.icon}"></i>`;
visBtn.title = defVis.label + ' — ' + defVis.hint;
this._closeAllPopups();
inp.focus();
});
spPanel.appendChild(opt);
});
spBtn.addEventListener('click', e => { e.stopPropagation(); this._togglePopup(spBtn, spPanel); });
spWrap.append(spBtn, platInput, spPanel);
// ── 2. Value input ───────────────────────────────────────────
const inp = document.createElement('input');
inp.className = 'form-input sle-entry-input';
inp.type = p ? p.type : 'text';
inp.name = `slink[${idx}][value]`;
inp.placeholder = p ? p.placeholder : 'select platform first';
inp.disabled = !key;
inp.value = value || '';
// ── 3. Visibility picker ─────────────────────────────────────
const visWrap = document.createElement('div');
visWrap.className = 'sle-popup-wrap';
const visInput = document.createElement('input');
visInput.type = 'hidden'; visInput.name = `slink[${idx}][visibility]`; visInput.value = vis.key;
const visBtn = document.createElement('button');
visBtn.type = 'button';
visBtn.className = 'sle-icon-btn sle-vis-btn';
visBtn.dataset.vis = vis.key;
visBtn.title = vis.label + ' — ' + vis.hint;
visBtn.innerHTML = `<i class="bi ${vis.icon}"></i>`;
const visPanel = document.createElement('div');
visPanel.className = 'sle-popup pop-right'; visPanel.hidden = true;
this.VISIBILITIES.forEach(v => {
const opt = document.createElement('div');
opt.className = `sle-pop-opt${v.key === vis.key ? ' active' : ''}`;
opt.style.color = v.key === vis.key ? v.color : '';
opt.innerHTML = `<i class="bi ${v.icon}" style="color:${v.color}"></i>`
+ `<span><strong>${v.label}</strong><br><small style="color:var(--text-secondary,#aaa)">${v.hint}</small></span>`;
opt.addEventListener('click', () => {
visInput.value = v.key;
visBtn.dataset.vis = v.key;
visBtn.innerHTML = `<i class="bi ${v.icon}"></i>`;
visBtn.title = v.label + ' — ' + v.hint;
visPanel.querySelectorAll('.sle-pop-opt').forEach(o => {
o.classList.remove('active'); o.style.color = '';
});
opt.classList.add('active'); opt.style.color = v.color;
visPanel.hidden = true;
});
visPanel.appendChild(opt);
});
visBtn.addEventListener('click', e => { e.stopPropagation(); this._togglePopup(visBtn, visPanel); });
visWrap.append(visInput, visBtn, visPanel);
// ── 4. Remove button ─────────────────────────────────────────
const rm = document.createElement('button');
rm.type = 'button'; rm.className = 'sle-icon-btn sle-remove-btn'; rm.title = 'Remove';
rm.innerHTML = '<i class="bi bi-trash"></i>';
rm.addEventListener('click', () => { row.remove(); this._renderEmpty(); });
row.append(spWrap, inp, visWrap, rm);
return row;
}
};
}());
</script>
@endonce
<div id="{{ $uid }}">
<div data-sle-entries></div>
<div style="margin-top:4px;">
<button type="button" data-sle-add class="action-btn">
<i class="bi bi-plus-lg"></i> <span>Add Account</span>
</button>
</div>
</div>
<script>
(function () {
function boot() { new SocialLinksEditor('{{ $uid }}', @json($existing)); }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}());
</script>
@@ -0,0 +1,234 @@
@props([
'name' => '',
'id' => null,
'value' => null,
'label' => null,
'placeholder' => 'Select timezone',
'required' => false,
'class' => '',
'style' => '',
])
@php
use App\Data\Countries;
$options = Countries::forTimezone();
$uid = 'csd_' . ($id ?? $name) . '_' . substr(md5(uniqid()), 0, 8);
$inputId = $id ?? $name;
$selFlag = '🌐';
$selLabel = $placeholder;
$selSub = '';
if ($value) {
foreach ($options as $opt) {
if ($opt['value'] === $value) {
$selFlag = $opt['flag'];
$selLabel = $opt['label'];
$selSub = $opt['utc'];
break;
}
}
}
$isPlaceholder = !$value;
@endphp
@once('csd-styles')
<style>
.csd-wrap { position: relative; }
.csd-lbl { display: block; margin-bottom: 6px; font-weight: 500; font-size: 14px; color: var(--text-primary, #f1f1f1); }
.csd-lbl .req { color: var(--brand-red, #e61e1e); margin-left: 2px; }
.csd-btn {
display: flex; align-items: center; gap: 8px; width: 100%;
background: var(--bg-dark, #0f0f0f); border: 1px solid var(--border-color, #303030);
border-radius: 8px; padding: 10px 14px; color: var(--text-primary, #f1f1f1);
font-size: 14px; font-family: inherit; line-height: 1.4; cursor: pointer;
text-align: left; transition: border-color .2s; outline: none; min-height: 46px; box-sizing: border-box;
}
.csd-btn:hover, .csd-btn:focus-visible { border-color: var(--brand-red, #e61e1e); }
.csd-btn[aria-expanded="true"] { border-color: var(--brand-red, #e61e1e); }
.csd-ico { font-size: 18px; line-height: 1; flex-shrink: 0; user-select: none; }
.csd-val { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.csd-val.ph { color: var(--text-secondary, #aaa); }
.csd-sub { font-size: 12px; color: var(--text-secondary, #aaa); white-space: nowrap; flex-shrink: 0; }
.csd-arr { flex-shrink: 0; font-size: 11px; color: var(--text-secondary, #aaa); margin-left: 4px; transition: transform .2s; }
.csd-btn[aria-expanded="true"] .csd-arr { transform: rotate(180deg); }
.csd-panel {
position: fixed; z-index: 1060; min-width: 220px;
background: var(--bg-secondary, #1e1e1e); border: 1px solid var(--border-color, #303030);
border-radius: 10px; box-shadow: 0 12px 32px rgba(0,0,0,.55); overflow: hidden;
}
.csd-srch { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border-color, #303030); }
.csd-srch i { font-size: 13px; color: var(--text-secondary, #aaa); flex-shrink: 0; }
.csd-sinput { flex: 1; background: none; border: none; outline: none; color: var(--text-primary, #f1f1f1); font-size: 13px; font-family: inherit; }
.csd-sinput::placeholder { color: var(--text-secondary, #aaa); }
.csd-list { list-style: none; margin: 0; padding: 4px 0; max-height: 240px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--border-color, #303030) transparent; }
.csd-list::-webkit-scrollbar { width: 4px; }
.csd-list::-webkit-scrollbar-thumb { background: var(--border-color, #303030); border-radius: 2px; }
.csd-opt { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; font-size: 13px; color: var(--text-primary, #f1f1f1); transition: background .1s; outline: none; }
.csd-opt:hover, .csd-opt:focus { background: rgba(255,255,255,.07); }
.csd-opt[aria-selected="true"] { background: rgba(255,255,255,.05); }
.csd-opt-ico { font-size: 17px; line-height: 1; flex-shrink: 0; user-select: none; }
.csd-opt-main { font-weight: 500; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.csd-opt-sub { font-size: 12px; color: var(--text-secondary, #aaa); white-space: nowrap; flex-shrink: 0; max-width: 45%; overflow: hidden; text-overflow: ellipsis; }
.csd-empty { padding: 12px; font-size: 13px; color: var(--text-secondary, #aaa); text-align: center; }
</style>
@endonce
@once('csd-script')
<script>
(function () {
if (window.CSD) return;
window.CSD = class {
constructor(id) {
this.root = document.getElementById(id);
this.btn = this.root.querySelector('.csd-btn');
this.panel = this.root.querySelector('.csd-panel');
this.sinput = this.root.querySelector('.csd-sinput');
this.list = this.root.querySelector('.csd-list');
this.hidden = this.root.querySelector('input[type=hidden]');
this.icoEl = this.btn.querySelector('.csd-ico');
this.valEl = this.btn.querySelector('.csd-val');
this.subEl = this.btn.querySelector('.csd-sub');
this.emptyEl= this.root.querySelector('.csd-empty');
this._bind();
if (this.root.dataset.geoMap && !this.hidden.value) {
this._autoGeo();
}
}
_bind() {
this.btn.addEventListener('click', e => { e.stopPropagation(); this.toggle(); });
this.sinput.addEventListener('input', () => this._filter());
this.list.addEventListener('click', e => { const o = e.target.closest('.csd-opt'); if (o) this._pick(o); });
document.addEventListener('click', e => { if (!this.root.contains(e.target)) this.close(); }, true);
this.btn.addEventListener('keydown', e => { if (e.key==='Enter'||e.key===' ') { e.preventDefault(); this.toggle(); } });
this.sinput.addEventListener('keydown', e => {
if (e.key==='Escape') { this.close(); this.btn.focus(); }
if (e.key==='ArrowDown') { e.preventDefault(); this._move(0); }
if (e.key==='Enter') { e.preventDefault(); }
});
this.list.addEventListener('keydown', e => {
const all = [...this.list.querySelectorAll('.csd-opt:not([hidden])')];
const i = all.indexOf(document.activeElement.closest('.csd-opt'));
if (e.key==='ArrowDown') { e.preventDefault(); this._move(i+1); }
if (e.key==='ArrowUp') { e.preventDefault(); i>0 ? this._move(i-1) : this.sinput.focus(); }
if (e.key==='Enter') { e.preventDefault(); if (all[i]) this._pick(all[i]); }
if (e.key==='Escape') { this.close(); this.btn.focus(); }
});
}
toggle() { this.panel.hidden ? this.open() : this.close(); }
open() {
document.querySelectorAll('.csd-panel:not([hidden])').forEach(p => { p.hidden = true; });
document.querySelectorAll('.csd-btn[aria-expanded=true]').forEach(b => b.setAttribute('aria-expanded','false'));
this.panel.hidden = false;
this.btn.setAttribute('aria-expanded','true');
this.sinput.value = '';
this._filter();
requestAnimationFrame(() => this.sinput.focus());
const r = this.btn.getBoundingClientRect();
const goUp = window.innerHeight - r.bottom < 280 && r.top > 280;
this.panel.style.left = r.left + 'px';
this.panel.style.width = Math.max(r.width, 220) + 'px';
if (goUp) {
this.panel.style.top = '';
this.panel.style.bottom = (window.innerHeight - r.top + 4) + 'px';
} else {
this.panel.style.top = (r.bottom + 4) + 'px';
this.panel.style.bottom = '';
}
}
close() { this.panel.hidden=true; this.btn.setAttribute('aria-expanded','false'); }
_filter() {
const q = this.sinput.value.toLowerCase(); let n=0;
this.list.querySelectorAll('.csd-opt').forEach(o => {
const show = o.dataset.s.includes(q);
o.style.display = show ? '' : 'none';
if (show) n++;
});
if (this.emptyEl) this.emptyEl.style.display = n > 0 ? 'none' : '';
}
_autoGeo() {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const map = JSON.parse(this.root.dataset.geoMap);
const iso2 = map[tz];
if (iso2) {
const opt = this.list.querySelector('[data-v="' + iso2 + '"]');
if (opt) this._pick(opt);
}
} catch (_) {}
}
_move(i) { const all=[...this.list.querySelectorAll('.csd-opt:not([hidden])')]; if(all[i]){all[i].tabIndex=0;all[i].focus();} }
_pick(opt) {
this.hidden.value = opt.dataset.v;
this.icoEl.textContent = opt.querySelector('.csd-opt-ico').textContent;
this.valEl.textContent = opt.querySelector('.csd-opt-main').textContent;
this.valEl.classList.remove('ph');
if (this.subEl) { const s=opt.querySelector('.csd-opt-sub'); this.subEl.textContent=s?s.textContent:''; }
this.list.querySelectorAll('.csd-opt').forEach(o => o.setAttribute('aria-selected', o===opt?'true':'false'));
this.close(); this.btn.focus();
this.hidden.dispatchEvent(new Event('change',{bubbles:true}));
}
};
}());
</script>
@endonce
<div class="csd-wrap {{ $class }}" id="{{ $uid }}" @if($style) style="{{ $style }}" @endif>
@if($label)
<label class="csd-lbl" for="{{ $inputId }}">
{{ $label }}@if($required)<span class="req">*</span>@endif
</label>
@endif
<button type="button"
class="csd-btn"
aria-haspopup="listbox"
aria-expanded="false"
aria-label="{{ $label ?? 'Select timezone' }}">
<span class="csd-ico">{{ $selFlag }}</span>
<span class="csd-val{{ $isPlaceholder ? ' ph' : '' }}">{{ $selLabel }}</span>
@if(!$isPlaceholder && $selSub)
<span class="csd-sub">{{ $selSub }}</span>
@endif
<i class="bi bi-chevron-down csd-arr"></i>
</button>
<div class="csd-panel" hidden role="listbox">
<div class="csd-srch">
<i class="bi bi-search"></i>
<input class="csd-sinput" type="text" placeholder="Search timezone or country…" autocomplete="off" spellcheck="false">
</div>
<ul class="csd-list">
@foreach($options as $opt)
<li class="csd-opt"
role="option"
tabindex="-1"
data-v="{{ $opt['value'] }}"
data-s="{{ $opt['search'] }}"
aria-selected="{{ $value === $opt['value'] ? 'true' : 'false' }}">
<span class="csd-opt-ico">{{ $opt['flag'] }}</span>
<span class="csd-opt-main">{{ $opt['label'] }}</span>
<span class="csd-opt-sub">{{ $opt['utc'] }}</span>
</li>
@endforeach
</ul>
<p class="csd-empty" hidden>No results</p>
</div>
<input type="hidden"
name="{{ $name }}"
id="{{ $inputId }}"
value="{{ $value }}"
@if($required) required @endif>
</div>
<script>
(function () {
function boot() { new CSD('{{ $uid }}'); }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}());
</script>
@@ -46,6 +46,16 @@
color: var(--brand-red) !important;
}
.action-btn.subscribed {
background: var(--brand-red);
border-color: var(--brand-red);
color: #fff !important;
}
.mobile-action-dropdown .dropdown-item.subscribed {
color: var(--brand-red) !important;
}
.mobile-action-dropdown {
display: none;
position: relative;
@@ -99,28 +109,36 @@
<div class="video-actions" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap; overflow: visible;">
@auth
@if (Auth::id() === $video->user_id)
<button class="action-btn desktop-action" onclick="openEditVideoModal({{ $video->id }})">
<button class="action-btn desktop-action" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
<i class="bi bi-pencil"></i>
<span>Edit</span>
</button>
@elseif (Auth::id() !== $video->user_id)
<button class="action-btn desktop-action"><i class="bi bi-bell"></i><span>Subscribe</span></button>
@php $isSubscribed = Auth::user()->isSubscribedTo($video->user); @endphp
<button type="button"
class="action-btn desktop-action subscribe-toggle-btn {{ $isSubscribed ? 'subscribed' : '' }}"
data-channel-id="{{ $video->user_id }}"
data-subscribe-url="{{ route('channel.subscribe', $video->user_id) }}"
data-subscribed="{{ $isSubscribed ? 'true' : 'false' }}">
<i class="bi {{ $isSubscribed ? 'bi-bell-fill' : 'bi-bell' }}"></i>
<span class="subscribe-label">{{ $isSubscribed ? 'Subscribed' : 'Subscribe' }}</span>
</button>
@endif
@else
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action"><i
class="bi bi-bell"></i><span>Subscribe</span></button>
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action">
<i class="bi bi-bell"></i><span>Subscribe</span>
</button>
@endauth
@auth
<form method="POST"
action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}"
class="d-inline desktop-action">
@csrf
<button type="submit" class="action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}">
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
</button>
</form>
<button type="button"
class="action-btn desktop-action like-toggle-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}"
data-video-id="{{ $video->id }}"
data-toggle-url="{{ route('videos.toggleLike', $video) }}"
data-liked="{{ $video->isLikedBy(Auth::user()) ? 'true' : 'false' }}">
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
<span class="like-count-label">{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}</span>
</button>
@else
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action">
<i class="bi bi-hand-thumbs-up"></i>
@@ -130,7 +148,7 @@
@if ($video->isShareable())
<button class="action-btn desktop-action"
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')">
<i class="bi bi-share"></i> Share
</button>
@endif
@@ -144,6 +162,51 @@
<span>Save</span>
</button>
@php
$dlAccess = $video->download_access ?? 'disabled';
$dlUser = Auth::user();
$showDl = match($dlAccess) {
'everyone' => true,
'registered' => (bool) $dlUser,
'subscribers' => $dlUser && ($dlUser->id === $video->user_id || $dlUser->isSubscribedTo($video->user)),
default => false,
};
@endphp
@if($showDl)
@php $isAudioDl = $video->isAudioOnly(); @endphp
<!-- Download Dropdown -->
<div class="dropdown desktop-action" style="display:inline-flex;">
<button class="action-btn dropdown-toggle" type="button" id="downloadDropdown{{ $video->id }}"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-download"></i>
<span>Download</span>
</button>
<ul class="dropdown-menu" aria-labelledby="downloadDropdown{{ $video->id }}"
style="background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:10px;padding:6px 0;min-width:160px;">
<li>
@if($isAudioDl)
<a class="dropdown-item" href="#"
onclick="startSlideshowDownload('{{ $video->getRouteKey() }}'); return false;"
style="color:var(--text-primary);display:flex;align-items:center;gap:8px;font-size:14px;padding:8px 14px;text-decoration:none;">
<i class="bi bi-film"></i> Download Video
</a>
@else
<a class="dropdown-item" href="{{ route('videos.download', $video) }}"
style="color:var(--text-primary);display:flex;align-items:center;gap:8px;font-size:14px;padding:8px 14px;text-decoration:none;">
<i class="bi bi-film"></i> Download Video
</a>
@endif
</li>
<li>
<a class="dropdown-item" href="{{ route('videos.downloadMp3', $video) }}"
style="color:var(--text-primary);display:flex;align-items:center;gap:8px;font-size:14px;padding:8px 14px;text-decoration:none;">
<i class="bi bi-music-note-beamed"></i> Download MP3
</a>
</li>
</ul>
</div>
@endif
<div class="dropdown mobile-action-dropdown">
<button class="action-btn dropdown-toggle" type="button" id="dropdownMenuLinkMusic{{ $video->id }}"
data-bs-toggle="dropdown" aria-expanded="false">
@@ -153,11 +216,17 @@
<div class="dropdown-menu" aria-labelledby="dropdownMenuLinkMusic{{ $video->id }}">
@auth
@if (Auth::id() !== $video->user_id)
<button type="button" class="dropdown-item">
<i class="bi bi-bell"></i> Subscribe
@php $isSubscribed = Auth::user()->isSubscribedTo($video->user); @endphp
<button type="button"
class="dropdown-item subscribe-toggle-btn {{ $isSubscribed ? 'subscribed' : '' }}"
data-channel-id="{{ $video->user_id }}"
data-subscribe-url="{{ route('channel.subscribe', $video->user_id) }}"
data-subscribed="{{ $isSubscribed ? 'true' : 'false' }}">
<i class="bi {{ $isSubscribed ? 'bi-bell-fill' : 'bi-bell' }}"></i>
<span class="subscribe-label">{{ $isSubscribed ? 'Subscribed' : 'Subscribe' }}</span>
</button>
@else
<button type="button" class="dropdown-item" onclick="openEditVideoModal({{ $video->id }})">
<button type="button" class="dropdown-item" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
<i class="bi bi-pencil"></i> Edit
</button>
@endif
@@ -168,16 +237,14 @@
@endauth
@auth
<form method="POST"
action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}"
class="d-inline w-100">
@csrf
<button type="submit" class="dropdown-item {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}">
<i
class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
</button>
</form>
<button type="button"
class="dropdown-item like-toggle-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}"
data-video-id="{{ $video->id }}"
data-toggle-url="{{ route('videos.toggleLike', $video) }}"
data-liked="{{ $video->isLikedBy(Auth::user()) ? 'true' : 'false' }}">
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
<span class="like-count-label">{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}</span>
</button>
@else
<button class="dropdown-item" onclick="window.location.href='{{ route('login') }}'">
<i class="bi bi-hand-thumbs-up"></i>
@@ -187,7 +254,7 @@
@if ($video->isShareable())
<button class="dropdown-item"
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')">
<i class="bi bi-share"></i> Share
</button>
@endif
@@ -195,11 +262,332 @@
<button class="dropdown-item" onclick="openAddToPlaylistModal({{ $video->id }})">
<i class="bi bi-bookmark"></i> Save
</button>
@if($showDl)
@if($isAudioDl)
<a class="dropdown-item" href="#"
onclick="startSlideshowDownload('{{ $video->getRouteKey() }}'); return false;"
style="color:var(--text-primary);display:flex;align-items:center;gap:8px;">
<i class="bi bi-film"></i> Download Video
</a>
@else
<a class="dropdown-item" href="{{ route('videos.download', $video) }}"
style="color:var(--text-primary);display:flex;align-items:center;gap:8px;">
<i class="bi bi-film"></i> Download Video
</a>
@endif
<a class="dropdown-item" href="{{ route('videos.downloadMp3', $video) }}"
style="color:var(--text-primary);display:flex;align-items:center;gap:8px;">
<i class="bi bi-music-note-beamed"></i> Download MP3
</a>
@endif
@if(Auth::check() && Auth::id() === $video->user_id)
<button type="button" class="dropdown-item text-danger" onclick="showDeleteModal({{ $video->id }}, '{{ addslashes($video->title) }}')">
<button type="button" class="dropdown-item text-danger" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})">
<i class="bi bi-trash"></i> Delete
</button>
@endif
</div>
</div>
</div>
<script>
(function () {
var csrfToken = '{{ csrf_token() }}';
// ── Subscribe toggle ─────────────────────────────────────────
document.addEventListener('click', function (e) {
var btn = e.target.closest('.subscribe-toggle-btn');
if (!btn) return;
var url = btn.dataset.subscribeUrl;
var subscribed = btn.dataset.subscribed === 'true';
var channelId = btn.dataset.channelId;
var nowSub = !subscribed;
// Optimistic update on all matching buttons (desktop + mobile)
document.querySelectorAll('.subscribe-toggle-btn[data-channel-id="' + channelId + '"]').forEach(function (b) {
b.dataset.subscribed = nowSub ? 'true' : 'false';
b.classList.toggle('subscribed', nowSub);
var icon = b.querySelector('i');
var label = b.querySelector('.subscribe-label');
if (icon) icon.className = 'bi ' + (nowSub ? 'bi-bell-fill' : 'bi-bell');
if (label) label.textContent = nowSub ? 'Subscribed' : 'Subscribe';
});
// Also update channel-row subscriber counts on the page
document.querySelectorAll('.channel-subs[data-channel-id="' + channelId + '"]').forEach(function (el) {
var n = parseInt(el.dataset.count || 0);
el.dataset.count = nowSub ? n + 1 : Math.max(0, n - 1);
el.textContent = Number(el.dataset.count).toLocaleString() + ' subscribers';
});
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' },
credentials: 'same-origin',
})
.then(function (r) {
if (!r.ok) throw new Error(r.status);
return r.json();
})
.then(function (data) {
document.querySelectorAll('.subscribe-toggle-btn[data-channel-id="' + channelId + '"]').forEach(function (b) {
b.dataset.subscribed = data.subscribed ? 'true' : 'false';
b.classList.toggle('subscribed', data.subscribed);
var icon = b.querySelector('i');
var label = b.querySelector('.subscribe-label');
if (icon) icon.className = 'bi ' + (data.subscribed ? 'bi-bell-fill' : 'bi-bell');
if (label) label.textContent = data.subscribed ? 'Subscribed' : 'Subscribe';
});
document.querySelectorAll('.channel-subs[data-channel-id="' + channelId + '"]').forEach(function (el) {
el.dataset.count = data.subscriber_count;
el.textContent = Number(data.subscriber_count).toLocaleString() + ' subscribers';
});
})
.catch(function (err) {
// Revert optimistic update
document.querySelectorAll('.subscribe-toggle-btn[data-channel-id="' + channelId + '"]').forEach(function (b) {
b.dataset.subscribed = subscribed ? 'true' : 'false';
b.classList.toggle('subscribed', subscribed);
var icon = b.querySelector('i');
var label = b.querySelector('.subscribe-label');
if (icon) icon.className = 'bi ' + (subscribed ? 'bi-bell-fill' : 'bi-bell');
if (label) label.textContent = subscribed ? 'Subscribed' : 'Subscribe';
});
if (typeof showToast === 'function') showToast('Could not update subscription. Please try again.', 'error');
});
});
document.addEventListener('click', function (e) {
var btn = e.target.closest('.like-toggle-btn');
if (!btn) return;
var url = btn.dataset.toggleUrl;
var liked = btn.dataset.liked === 'true';
// Optimistic UI update
var allBtns = document.querySelectorAll('.like-toggle-btn[data-video-id="' + btn.dataset.videoId + '"]');
allBtns.forEach(function (b) {
var icon = b.querySelector('i');
var label = b.querySelector('.like-count-label');
var nowLiked = !liked;
b.dataset.liked = nowLiked ? 'true' : 'false';
b.classList.toggle('liked', nowLiked);
if (icon) {
icon.className = 'bi ' + (nowLiked ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up');
}
if (label) {
var current = parseInt(label.textContent.replace(/,/g, '')) || 0;
var next = nowLiked ? current + 1 : Math.max(0, current - 1);
label.textContent = next > 0 ? next.toLocaleString() : 'Like';
}
});
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' },
})
.then(function (r) { return r.json(); })
.then(function (data) {
// Reconcile with server truth
allBtns.forEach(function (b) {
var icon = b.querySelector('i');
var label = b.querySelector('.like-count-label');
b.dataset.liked = data.liked ? 'true' : 'false';
b.classList.toggle('liked', data.liked);
if (icon) icon.className = 'bi ' + (data.liked ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up');
if (label) label.textContent = data.like_count > 0 ? data.like_count.toLocaleString() : 'Like';
});
})
.catch(function () {
// Revert optimistic update on failure
allBtns.forEach(function (b) {
var icon = b.querySelector('i');
b.dataset.liked = liked ? 'true' : 'false';
b.classList.toggle('liked', liked);
if (icon) icon.className = 'bi ' + (liked ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up');
});
if (typeof toast === 'function') toast('Could not update like. Try again.', 'error');
});
});
})();
// ── Slideshow video generation progress ──────────────────────────────
if (!window._slideshowDlInit) {
window._slideshowDlInit = true;
// Inject progress overlay once
var _overlay = document.createElement('div');
_overlay.id = 'sl-dl-overlay';
_overlay.innerHTML = `
<div id="sl-dl-box">
<div id="sl-dl-icon"><i class="bi bi-film"></i></div>
<h5 id="sl-dl-title">Generating Video</h5>
<p id="sl-dl-status">Starting...</p>
<div id="sl-dl-track">
<div id="sl-dl-bar"></div>
</div>
<div id="sl-dl-pct">0%</div>
<p id="sl-dl-hint">This may take a minute for long tracks</p>
<button id="sl-dl-cancel" onclick="window._slideshowDlCancel()">Cancel</button>
</div>`;
document.body.appendChild(_overlay);
// Styles
var _style = document.createElement('style');
_style.textContent = `
#sl-dl-overlay {
display: none; position: fixed; inset: 0; z-index: 99999;
background: rgba(0,0,0,.85); align-items: center; justify-content: center;
}
#sl-dl-overlay.active { display: flex; }
#sl-dl-box {
background: #1e1e1e; border: 1px solid #333; border-radius: 18px;
padding: 36px 32px; width: 90%; max-width: 400px; text-align: center;
box-shadow: 0 24px 80px rgba(0,0,0,.7);
}
#sl-dl-icon { font-size: 44px; color: #e61e1e; margin-bottom: 14px; }
#sl-dl-title { color: #fff; font-size: 18px; font-weight: 600; margin: 0 0 6px; }
#sl-dl-status { color: #aaa; font-size: 13px; margin: 0 0 20px; min-height: 18px; }
#sl-dl-track {
background: #2a2a2a; border-radius: 6px; height: 10px;
overflow: hidden; margin-bottom: 10px;
}
#sl-dl-bar {
background: linear-gradient(90deg, #e61e1e, #ff4757);
height: 100%; width: 0%; border-radius: 6px;
transition: width .6s ease;
}
#sl-dl-pct { color: #e61e1e; font-size: 22px; font-weight: 700; margin-bottom: 4px; }
#sl-dl-hint { color: #555; font-size: 11px; margin: 8px 0 16px; }
#sl-dl-cancel {
background: none; border: 1px solid #444; color: #888;
border-radius: 8px; padding: 6px 18px; font-size: 13px; cursor: pointer;
}
#sl-dl-cancel:hover { border-color: #e61e1e; color: #e61e1e; }
`;
document.head.appendChild(_style);
var _pollTimer = null;
var _currentKey = null;
var _maxPct = 0; // progress only ever moves forward
window._slideshowDlCancel = function () {
clearInterval(_pollTimer);
_pollTimer = null;
_currentKey = null;
_maxPct = 0;
document.getElementById('sl-dl-overlay').classList.remove('active');
};
function _setProgress(pct, status) {
// Never decrease the bar — only move forward (unless resetting to 0 on error/cancel)
pct = Math.max(pct, _maxPct);
_maxPct = pct;
document.getElementById('sl-dl-bar').style.width = pct + '%';
document.getElementById('sl-dl-pct').textContent = pct + '%';
document.getElementById('sl-dl-status').textContent = status;
}
function _setError(msg) {
_maxPct = 0;
document.getElementById('sl-dl-bar').style.width = '0%';
document.getElementById('sl-dl-bar').style.background = '#e61e1e';
document.getElementById('sl-dl-pct').textContent = '✕';
document.getElementById('sl-dl-status').textContent = msg || 'Generation failed. Please try again.';
document.getElementById('sl-dl-cancel').textContent = 'Close';
}
window.startSlideshowDownload = function (routeKey) {
if (_currentKey && _currentKey !== routeKey) {
// Different video — reset
clearInterval(_pollTimer);
_pollTimer = null;
}
_currentKey = routeKey;
_maxPct = 0;
document.getElementById('sl-dl-bar').style.background = ''; // restore gradient
document.getElementById('sl-dl-cancel').textContent = 'Cancel';
_setProgress(0, 'Starting...');
document.getElementById('sl-dl-overlay').classList.add('active');
document.getElementById('sl-dl-cancel').style.display = '';
// Use the global csrf variable set by the layout, with meta tag as fallback
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
var token = (typeof csrf !== 'undefined' ? csrf : '') || (csrfMeta ? csrfMeta.getAttribute('content') : '');
fetch('/videos/' + routeKey + '/slideshow/generate', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': token, 'Accept': 'application/json' }
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.status === 'ready') {
_maxPct = 100;
document.getElementById('sl-dl-bar').style.width = '100%';
document.getElementById('sl-dl-pct').textContent = '100%';
document.getElementById('sl-dl-status').textContent = 'Ready! Starting download...';
document.getElementById('sl-dl-cancel').style.display = 'none';
setTimeout(function () {
window._slideshowDlCancel();
window.location.href = '/videos/' + routeKey + '/download';
}, 600);
return;
}
if (data.error) {
_setError('Error: ' + data.error);
return;
}
var duration = data.duration || 0;
_setProgress(2, 'Generating video...');
_pollTimer = setInterval(function () {
fetch('/videos/' + routeKey + '/slideshow/progress?duration=' + duration, {
headers: { 'Accept': 'application/json' }
})
.then(function (r) { return r.json(); })
.then(function (prog) {
if (prog.status === 'error') {
clearInterval(_pollTimer);
_pollTimer = null;
_setError(prog.message || null);
return;
}
if (prog.status === 'ready') {
clearInterval(_pollTimer);
_pollTimer = null;
_maxPct = 100;
document.getElementById('sl-dl-bar').style.width = '100%';
document.getElementById('sl-dl-pct').textContent = '100%';
document.getElementById('sl-dl-status').textContent = 'Ready! Starting download...';
document.getElementById('sl-dl-cancel').style.display = 'none';
setTimeout(function () {
window._slideshowDlCancel();
window.location.href = '/videos/' + routeKey + '/download';
}, 600);
return;
}
// 'waiting' means the process just started — keep the current bar position
if (prog.status === 'waiting') return;
var pct = prog.percent || 0;
var remaining = '';
if (duration > 0 && pct > 2) {
var secs = Math.round(duration * (100 - pct) / pct);
remaining = secs > 60
? ' (~' + Math.ceil(secs / 60) + ' min left)'
: ' (~' + secs + 's left)';
}
_setProgress(pct, 'Generating video...' + remaining);
})
.catch(function () { /* ignore transient poll errors */ });
}, 1500);
})
.catch(function () {
_setProgress(0, 'Failed to start. Please try again.');
});
};
}
</script>
+178 -42
View File
@@ -26,14 +26,21 @@ $sizeClasses = match($size) {
@endphp
<div class="yt-video-card {{ $sizeClasses }}" data-video-url="{{ $videoUrl }}">
<a href="{{ $video ? route('videos.show', $video->id) : '#' }}">
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)">
<a href="{{ $video ? route('videos.show', $video) : '#' }}">
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}">
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}">
@if($videoUrl)
<video preload="none">
<source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}">
</video>
@endif
{{-- Equalizer overlay shown when audio-only track is previewing --}}
<div class="audio-preview-overlay">
<div class="audio-eq">
<span></span><span></span><span></span><span></span><span></span>
</div>
</div>
@if($video && $video->duration)
<span class="yt-video-duration">{{ gmdate('i:s', $video->duration) }}</span>
@endif
@@ -42,27 +49,44 @@ $sizeClasses = match($size) {
<i class="bi bi-collection-play-fill"></i> SHORTS
</span>
@endif
@if($isOwner && $video->visibility === 'private')
<span class="yt-visibility-badge yt-visibility-private">
<i class="bi bi-lock-fill"></i> Private
</span>
@elseif($isOwner && $video->visibility === 'unlisted')
<span class="yt-visibility-badge yt-visibility-unlisted">
<i class="bi bi-link-45deg"></i> Unlisted
</span>
@endif
</div>
</a>
<div class="yt-video-info">
<div class="yt-channel-icon">
<a href="{{ $video && $video->user ? route('channel', $video->user->channel) : '#' }}"
class="yt-channel-icon" onclick="event.stopPropagation();">
@if($video && $video->user && $video->user->avatar_url)
<img src="{{ $video->user->avatar_url }}" alt="{{ $video->user->name }}" style="width: 100%; height: 100%; object-fit: cover; border-radius: 50%;">
@endif
</div>
</a>
<div class="yt-video-details">
<h3 class="yt-video-title">
<a href="{{ $video ? route('videos.show', $video->id) : '#' }}">
<i class="bi {{ $typeIcon }}" style="color: #ef4444; margin-right: 6px;"></i>
<a href="{{ $video ? route('videos.show', $video) : '#' }}">
{{ $video->title ?? 'Untitled Video' }}
</a>
</h3>
@if($video && $video->user)
<div class="yt-channel-name">{{ $video->user->name }}</div>
<a href="{{ route('channel', $video->user->channel) }}"
class="yt-channel-name" onclick="event.stopPropagation();">{{ $video->user->name }}</a>
@endif
@if($video)
<div class="yt-video-meta">
{{ number_format($video->view_count) }} views • {{ $video->created_at->diffForHumans() }}
@if($video->type && $video->type !== 'generic')
<span class="yt-type-label yt-type-{{ $video->type }}">
<i class="bi {{ $typeIcon }}"></i>
{{ ucfirst($video->type === 'match' ? 'Sports' : $video->type) }}
</span>
&nbsp;·&nbsp;
@endif
{{ number_format($video->view_count) }} views · {{ $video->created_at->diffForHumans() }}
</div>
@endif
</div>
@@ -74,47 +98,49 @@ $sizeClasses = match($size) {
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
@if($isOwner)
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="openEditVideoModal('{{ $video->id }}')">
<a class="dropdown-item" href="javascript:void(0)" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
<i class="bi bi-pencil"></i> Edit
</a>
</li>
<li>
<button type="button" class="dropdown-item text-danger" onclick="showDeleteModal('{{ $video->id }}', '{{ addslashes($video->title) }}')">
<button type="button" class="dropdown-item text-danger" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})">
<i class="bi bi-trash"></i> Delete
</button>
</li>
<li><hr class="dropdown-divider"></li>
@endif
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="addToQueue('{{ $video->id }}')">
<a class="dropdown-item" href="javascript:void(0)" onclick="addToQueue('{{ $video->getRouteKey() }}')">
<i class="bi bi-list-nested"></i> Add to queue
</a>
</li>
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="saveToWatchLater('{{ $video->id }}')">
<a class="dropdown-item" href="javascript:void(0)" onclick="saveToWatchLater('{{ $video->getRouteKey() }}')">
<i class="bi bi-clock"></i> Save to Watch later
</a>
</li>
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="openPlaylistModal('{{ $video->id }}')">
<a class="dropdown-item" href="javascript:void(0)" onclick="openPlaylistModal('{{ $video->getRouteKey() }}')">
<i class="bi bi-bookmark"></i> Save to playlist
</a>
</li>
@if($video->allow_download)
<li>
<a class="dropdown-item" href="{{ route('videos.download', $video->id) }}">
<a class="dropdown-item" href="{{ route('videos.download', $video) }}">
<i class="bi bi-download"></i> Download
</a>
</li>
@endif
@if($video->isShareable())
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
<a class="dropdown-item" href="javascript:void(0)" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')">
<i class="bi bi-share"></i> Share
</a>
</li>
@endif
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="notInterested('{{ $video->id }}')">
<a class="dropdown-item" href="javascript:void(0)" onclick="notInterested('{{ $video->getRouteKey() }}')">
<i class="bi bi-dash-circle"></i> Not interested
</a>
</li>
@@ -124,7 +150,7 @@ $sizeClasses = match($size) {
</a>
</li>
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="reportVideo('{{ $video->id }}')">
<a class="dropdown-item" href="javascript:void(0)" onclick="reportVideo('{{ $video->getRouteKey() }}')">
<i class="bi bi-flag"></i> Report
</a>
</li>
@@ -134,30 +160,31 @@ $sizeClasses = match($size) {
</div>
</div>
@php $vk = $video->getRouteKey() ?? ''; @endphp
<!-- Cute Edit Video Modal -->
<div class="modal fade" id="editVideoModal{{ $video->id ?? '' }}" tabindex="-1" aria-labelledby="editVideoModalLabel{{ $video->id ?? '' }}" aria-hidden="true">
<div class="modal fade" id="editVideoModal{{ $vk }}" tabindex="-1" aria-labelledby="editVideoModalLabel{{ $vk }}" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered cute-edit-modal">
<div class="modal-content cute-edit-content">
<div class="cute-edit-header">
<span class="cute-edit-icon">✏️</span>
<h5>Edit Video</h5>
<button type="button" class="btn-close-cute" onclick="closeEditVideoModal('{{ $video->id ?? '' }}')">×</button>
<button type="button" class="btn-close-cute" onclick="closeEditVideoModal('{{ $vk }}')">×</button>
</div>
<div class="cute-edit-body">
<form id="edit-video-form-{{ $video->id ?? '' }}" enctype="multipart/form-data">
<form id="edit-video-form-{{ $vk }}" enctype="multipart/form-data">
@csrf
@method('PUT')
<!-- Title -->
<div class="cute-form-group">
<label><i class="bi bi-card-heading"></i> Title</label>
<input type="text" name="title" id="edit-title-{{ $video->id ?? '' }}" class="cute-input" placeholder="Video title">
<input type="text" name="title" id="edit-title-{{ $vk }}" class="cute-input" placeholder="Video title">
</div>
<!-- Description -->
<div class="cute-form-group">
<label><i class="bi bi-text-paragraph"></i> Description</label>
<textarea name="description" id="edit-description-{{ $video->id ?? '' }}" class="cute-textarea" rows="2" placeholder="Tell viewers about your video"></textarea>
<textarea name="description" id="edit-description-{{ $vk }}" class="cute-textarea" rows="2" placeholder="Tell viewers about your video"></textarea>
</div>
<!-- Video Type -->
@@ -183,7 +210,7 @@ $sizeClasses = match($size) {
<div class="cute-form-group">
<label><i class="bi bi-lightning-charge-fill"></i> Shorts</label>
<label class="cute-shorts-toggle">
<input type="checkbox" name="is_shorts" id="edit-is-shorts-{{ $video->id ?? '' }}" value="1">
<input type="checkbox" name="is_shorts" id="edit-is-shorts-{{ $vk }}" value="1">
<span class="cute-shorts-slider"></span>
<span class="cute-shorts-label">Mark as Short</span>
</label>
@@ -192,9 +219,9 @@ $sizeClasses = match($size) {
<!-- Thumbnail -->
<div class="cute-form-group">
<label><i class="bi bi-image"></i> Thumbnail</label>
<div class="cute-thumbnail-upload" onclick="document.getElementById('edit-thumbnail-{{ $video->id ?? '' }}').click()">
<input type="file" name="thumbnail" id="edit-thumbnail-{{ $video->id ?? '' }}" accept="image/*" hidden>
<div class="cute-thumbnail-preview" id="thumbnail-preview-{{ $video->id ?? '' }}">
<div class="cute-thumbnail-upload" onclick="document.getElementById('edit-thumbnail-{{ $vk }}').click()">
<input type="file" name="thumbnail" id="edit-thumbnail-{{ $vk }}" accept="image/*" hidden>
<div class="cute-thumbnail-preview" id="thumbnail-preview-{{ $vk }}">
<i class="bi bi-camera"></i>
<span>Click to change</span>
</div>
@@ -221,11 +248,11 @@ $sizeClasses = match($size) {
</div>
<!-- Status -->
<div class="cute-status" id="edit-status-{{ $video->id ?? '' }}"></div>
<div class="cute-status" id="edit-status-{{ $vk }}"></div>
<!-- Buttons -->
<div class="cute-edit-actions">
<button type="button" class="cute-btn-cancel" onclick="closeEditVideoModal('{{ $video->id ?? '' }}')">Cancel</button>
<button type="button" class="cute-btn-cancel" onclick="closeEditVideoModal('{{ $vk }}')">Cancel</button>
<button type="submit" class="cute-btn-save">Save</button>
</div>
</form>
@@ -255,6 +282,10 @@ $sizeClasses = match($size) {
position: absolute;
top: 0;
left: 0;
transition: transform 0.2s ease;
}
.yt-video-card:hover .yt-video-thumb img {
transform: scale(1.03);
}
.yt-video-card .yt-video-thumb video {
@@ -273,6 +304,52 @@ $sizeClasses = match($size) {
opacity: 1;
}
/* Audio preview overlay (equalizer) */
.audio-preview-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
justify-content: center;
padding-bottom: 14px;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.yt-video-thumb.audio-playing .audio-preview-overlay {
opacity: 1;
}
.audio-eq {
display: flex;
align-items: flex-end;
gap: 3px;
height: 24px;
background: rgba(0,0,0,0.55);
padding: 4px 8px;
border-radius: 20px;
}
.audio-eq span {
display: block;
width: 3px;
border-radius: 2px;
background: #e61e1e;
animation: eq-bar 0.8s ease-in-out infinite;
}
.audio-eq span:nth-child(1) { height: 6px; animation-delay: 0s; }
.audio-eq span:nth-child(2) { height: 14px; animation-delay: 0.15s; }
.audio-eq span:nth-child(3) { height: 20px; animation-delay: 0.05s; }
.audio-eq span:nth-child(4) { height: 12px; animation-delay: 0.2s; }
.audio-eq span:nth-child(5) { height: 8px; animation-delay: 0.1s; }
@keyframes eq-bar {
0%, 100% { transform: scaleY(0.4); }
50% { transform: scaleY(1); }
}
.yt-video-card .yt-video-duration {
position: absolute;
bottom: 8px;
@@ -306,6 +383,31 @@ $sizeClasses = match($size) {
font-size: 12px;
}
.yt-video-card .yt-visibility-badge {
position: absolute;
bottom: 8px;
left: 8px;
padding: 3px 7px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
gap: 4px;
pointer-events: none;
}
.yt-video-card .yt-visibility-private {
background: rgba(220, 38, 38, 0.88);
color: #fff;
}
.yt-video-card .yt-visibility-unlisted {
background: rgba(30, 30, 30, 0.82);
color: #facc15;
border: 1px solid rgba(250, 204, 21, 0.4);
}
.yt-video-card .yt-video-info {
display: flex;
margin-top: 12px;
@@ -343,6 +445,11 @@ $sizeClasses = match($size) {
text-decoration: none;
}
.yt-video-card .yt-channel-icon {
text-decoration: none;
display: block;
}
.yt-video-card .yt-channel-name,
.yt-video-card .yt-video-meta {
color: #aaa;
@@ -352,7 +459,23 @@ $sizeClasses = match($size) {
text-overflow: ellipsis;
}
/* More button */
.yt-video-card a.yt-channel-name {
display: block;
text-decoration: none;
}
.yt-video-card a.yt-channel-name:hover { color: #fff; }
/* Type label in meta row */
.yt-type-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.yt-type-music { color: #c084fc; }
.yt-type-match { color: #60a5fa; }
/* More button — visible only on hover (touch devices always show it) */
.yt-video-card .yt-more-btn {
background: transparent;
border: none;
@@ -365,10 +488,15 @@ $sizeClasses = match($size) {
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
transition: background 0.2s, opacity 0.15s;
flex-shrink: 0;
}
@media (hover: hover) {
.yt-video-card .yt-more-btn { opacity: 0; }
.yt-video-card:hover .yt-more-btn,
.yt-video-card .yt-more-btn:focus,
.yt-video-card .yt-more-btn[aria-expanded="true"] { opacity: 1; }
}
.yt-video-card .yt-more-btn:hover {
background: #3f3f3f;
}
@@ -746,6 +874,7 @@ $sizeClasses = match($size) {
}
</style>
@once
<script>
// Global function to save to Watch Later
function saveToWatchLater(videoId) {
@@ -760,7 +889,7 @@ function saveToWatchLater(videoId) {
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message || 'Added to Watch Later');
showToast(data.message || 'Added to Watch Later', 'success');
}
})
.catch(error => console.error('Error:', error));
@@ -801,26 +930,32 @@ function openPlaylistModal(videoId) {
// Global function to add to queue
function addToQueue(videoId) {
alert('Queue feature coming soon!');
showToast('Queue feature coming soon!', 'info');
}
function playVideo(element) {
const video = element.querySelector('video');
if (video) {
video.currentTime = 0;
video.volume = 0.10;
video.play().catch(function(e) {});
if (!video) return;
video.currentTime = 0;
const isAudio = element.dataset.audio === 'true';
video.volume = isAudio ? 0.4 : 0.10;
if (isAudio) {
// Keep thumbnail visible — just play audio, show equalizer
video.play().catch(function() {});
element.classList.add('audio-playing');
} else {
video.play().catch(function() {});
video.classList.add('active');
}
}
function stopVideo(element) {
const video = element.querySelector('video');
if (video) {
video.pause();
video.currentTime = 0;
video.classList.remove('active');
}
if (!video) return;
video.pause();
video.currentTime = 0;
video.classList.remove('active');
element.classList.remove('audio-playing');
}
// Edit Modal Functions
@@ -967,3 +1102,4 @@ document.addEventListener('submit', function(e) {
}
});
</script>
@endonce
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,785 @@
{{--
Video Insights component.
Expects: $video (Video model)
Renders the Insights tab panel + modal + all JS — only for the video owner.
--}}
@php
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
@endphp
@if($isVideoOwner)
<style>
/* ── Stat cards ──────────────────────────────────────── */
.ins-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin-bottom:18px; }
@media(max-width:680px){ .ins-grid { grid-template-columns:repeat(2,1fr); } }
.ins-card { background:var(--bg-dark); border:1px solid var(--border-color); border-radius:10px; padding:14px 14px 12px; display:flex; flex-direction:column; gap:4px; cursor:pointer; transition:border-color .15s, transform .12s, box-shadow .15s; user-select:none; }
.ins-card:hover { border-color:rgba(239,68,68,.5); transform:translateY(-2px); box-shadow:0 4px 16px rgba(0,0,0,.3); }
.ins-card:active { transform:translateY(0); }
.ins-card-icon { font-size:18px; margin-bottom:2px; }
.ins-card-val { font-size:26px; font-weight:900; line-height:1; color:var(--text-primary); }
.ins-card-label { font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.05em; color:var(--text-secondary); }
.ins-card-sub { font-size:12px; margin-top:3px; font-weight:600; }
.ins-card-sub.up { color:#4ade80; } .ins-card-sub.down { color:#f87171; } .ins-card-sub.neu { color:var(--text-secondary); }
/* ── Section titles / charts ────────────────────────── */
.ins-section-title { font-size:12px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--text-secondary); margin-bottom:12px; display:flex; align-items:center; gap:6px; }
.ins-chart { display:flex; align-items:flex-end; gap:4px; height:80px; margin-bottom:6px; }
.ins-bar-col { flex:1; display:flex; flex-direction:column; align-items:center; gap:4px; min-width:0; }
.ins-bar { width:100%; border-radius:4px 4px 0 0; background:#ef444466; transition:height .6s cubic-bezier(.22,.61,.36,1), background .15s; min-height:2px; }
.ins-bar.clickable { cursor:pointer; }
.ins-bar.clickable:hover { background:#ef4444cc !important; }
.ins-bar-today { background:#ef4444 !important; }
.ins-bar-label { font-size:9px; color:var(--text-secondary); text-align:center; overflow:hidden; white-space:nowrap; }
.ins-peak-badge { display:inline-flex; align-items:center; gap:6px; background:rgba(239,68,68,.12); border:1px solid rgba(239,68,68,.25); border-radius:20px; padding:4px 12px; font-size:12px; font-weight:600; color:#fca5a5; }
.ins-body { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
@media(max-width:600px){ .ins-body { grid-template-columns:1fr; } }
/* ── Country rows ───────────────────────────────────── */
.ins-country-row { display:flex; align-items:center; gap:10px; padding:7px 0; border-bottom:1px solid rgba(255,255,255,.04); cursor:pointer; border-radius:6px; transition:background .12s; }
.ins-country-row:last-child { border-bottom:none; }
.ins-country-row:hover { background:rgba(255,255,255,.04); padding-left:6px; }
.ins-country-flag { font-size:20px; flex-shrink:0; width:28px; text-align:center; line-height:1; }
.ins-country-name { flex:1; font-size:13px; font-weight:500; color:var(--text-primary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ins-country-bar-wrap { width:100px; height:5px; background:rgba(255,255,255,.07); border-radius:4px; overflow:hidden; flex-shrink:0; }
.ins-country-bar { height:100%; background:#ef4444; border-radius:4px; }
.ins-country-pct { font-size:12px; font-weight:700; color:var(--text-secondary); min-width:34px; text-align:right; }
.ins-country-cnt { font-size:11px; color:var(--text-secondary); min-width:40px; text-align:right; }
/* ── Download rows ───────────────────────────────────── */
.ins-dl-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; flex-wrap:wrap; gap:8px; }
.ins-dl-type-pills { display:flex; gap:6px; }
.ins-dl-pill { display:inline-flex; align-items:center; gap:5px; border-radius:20px; padding:3px 11px; font-size:12px; font-weight:600; }
.ins-dl-pill.video { border:1px solid rgba(239,68,68,.3); color:#fca5a5; background:rgba(239,68,68,.08); }
.ins-dl-pill.mp3 { border:1px solid rgba(96,165,250,.3); color:#93c5fd; background:rgba(96,165,250,.08); }
.ins-dl-pill.guest { border:1px solid rgba(255,255,255,.12); color:var(--text-secondary); background:rgba(255,255,255,.05); }
.ins-dl-user-row { display:flex; align-items:center; gap:10px; padding:8px 6px; border-bottom:1px solid rgba(255,255,255,.04); cursor:pointer; border-radius:8px; transition:background .12s; }
.ins-dl-user-row:last-child { border-bottom:none; }
.ins-dl-user-row:hover { background:rgba(255,255,255,.05); }
.ins-dl-avatar { width:34px; height:34px; border-radius:50%; object-fit:cover; flex-shrink:0; background:rgba(255,255,255,.08); }
.ins-dl-user-name { flex:1; font-size:13px; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ins-dl-user-meta { font-size:11px; color:var(--text-secondary); margin-top:1px; }
.ins-dl-count-badge { flex-shrink:0; background:rgba(239,68,68,.12); border:1px solid rgba(239,68,68,.25); color:#fca5a5; border-radius:20px; font-size:12px; font-weight:700; padding:2px 10px; }
.ins-recent-row { display:flex; align-items:center; gap:10px; padding:7px 0; border-bottom:1px solid rgba(255,255,255,.04); font-size:12px; }
.ins-recent-row:last-child { border-bottom:none; }
.ins-recent-avatar { width:28px; height:28px; border-radius:50%; object-fit:cover; flex-shrink:0; background:rgba(255,255,255,.08); display:flex; align-items:center; justify-content:center; font-size:14px; }
.ins-recent-name { flex:1; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ins-recent-type { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; padding:2px 7px; border-radius:6px; flex-shrink:0; }
.ins-recent-type.video { background:rgba(239,68,68,.12); color:#fca5a5; }
.ins-recent-type.mp3 { background:rgba(96,165,250,.1); color:#93c5fd; }
.ins-recent-time { color:var(--text-secondary); flex-shrink:0; min-width:70px; text-align:right; }
.ins-recent-flag { font-size:16px; flex-shrink:0; }
/* ── Demographics (gender / age) ─────────────────────── */
.ins-demo-row { display:flex; align-items:center; gap:8px; padding:5px 0; }
.ins-demo-sym { font-size:16px; flex-shrink:0; width:18px; text-align:center; }
.ins-demo-label { font-size:12px; font-weight:600; color:var(--text-primary); min-width:52px; }
.ins-age-label { min-width:68px; }
.ins-demo-bar-wrap { flex:1; height:6px; background:rgba(255,255,255,.07); border-radius:4px; overflow:hidden; }
.ins-demo-bar { height:100%; border-radius:4px; transition:width .6s cubic-bezier(.22,.61,.36,1); }
.ins-demo-pct { font-size:12px; font-weight:700; color:var(--text-secondary); min-width:34px; text-align:right; }
.ins-demo-cnt { font-size:11px; color:var(--text-secondary); min-width:30px; text-align:right; }
/* ── Skeleton ─────────────────────────────────────────── */
.ins-skeleton { display:flex; flex-direction:column; gap:12px; padding:4px 0; }
.ins-skel-row { height:14px; border-radius:6px; background:rgba(255,255,255,.06); animation:skelPulse 1.4s ease-in-out infinite; }
@keyframes skelPulse { 0%,100%{opacity:.5} 50%{opacity:1} }
/* ── Insight modal ───────────────────────────────────── */
.ins-modal-backdrop { position:fixed; inset:0; background:rgba(0,0,0,.72); z-index:9200; display:flex; align-items:center; justify-content:center; padding:16px; animation:insBackIn .15s ease; }
@keyframes insBackIn { from{opacity:0} to{opacity:1} }
.ins-modal { background:var(--bg-secondary); border:1px solid var(--border-color); border-radius:18px; width:100%; max-width:500px; max-height:84vh; display:flex; flex-direction:column; overflow:hidden; animation:insModalIn .22s cubic-bezier(.34,1.4,.64,1); }
@keyframes insModalIn { from{opacity:0;transform:scale(.88) translateY(20px)} to{opacity:1;transform:none} }
.ins-modal-head { display:flex; align-items:center; gap:12px; padding:16px 18px 13px; border-bottom:1px solid var(--border-color); flex-shrink:0; }
.ins-modal-icon { font-size:26px; line-height:1; }
.ins-modal-titles { flex:1; min-width:0; }
.ins-modal-title { font-size:15px; font-weight:700; color:var(--text-primary); }
.ins-modal-subtitle { font-size:12px; color:var(--text-secondary); margin-top:1px; }
.ins-modal-close { background:rgba(255,255,255,.06); border:none; color:var(--text-secondary); border-radius:50%; width:30px; height:30px; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:16px; transition:background .15s,color .15s; flex-shrink:0; }
.ins-modal-close:hover { background:rgba(239,68,68,.2); color:#fca5a5; }
.ins-modal-body { padding:18px 18px 22px; overflow-y:auto; flex:1; }
/* Modal content pieces */
.ins-modal-hero { text-align:center; padding:4px 0 20px; }
.ins-modal-hero-num { font-size:52px; font-weight:900; line-height:1; color:var(--text-primary); }
.ins-modal-hero-flag { font-size:56px; line-height:1; margin-bottom:4px; }
.ins-modal-hero-label { font-size:13px; color:var(--text-secondary); margin-top:6px; }
.ins-modal-stat { display:flex; justify-content:space-between; align-items:center; padding:9px 0; border-bottom:1px solid rgba(255,255,255,.05); font-size:13px; }
.ins-modal-stat:last-child { border-bottom:none; }
.ins-modal-stat-lbl { color:var(--text-secondary); font-weight:500; display:flex; align-items:center; gap:7px; }
.ins-modal-stat-val { font-weight:700; color:var(--text-primary); }
.ins-modal-section { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--text-secondary); margin:18px 0 10px; display:flex; align-items:center; gap:6px; }
.ins-modal-section:first-child { margin-top:0; }
/* Hourly chart (24 bars) */
.ins-hourly-chart { display:flex; align-items:flex-end; gap:2px; height:60px; margin-bottom:5px; }
.ins-hourly-col { flex:1; display:flex; flex-direction:column; align-items:center; gap:2px; min-width:0; }
.ins-hourly-bar { width:100%; border-radius:3px 3px 0 0; background:#ef444455; min-height:2px; transition:height .5s cubic-bezier(.22,.61,.36,1); }
.ins-hourly-bar.peak { background:#ef4444; }
.ins-hourly-lbl { font-size:7px; color:var(--text-secondary); text-align:center; overflow:hidden; white-space:nowrap; }
/* Person list */
.ins-person-row { display:flex; align-items:center; gap:10px; padding:9px 0; border-bottom:1px solid rgba(255,255,255,.04); cursor:pointer; border-radius:6px; transition:background .12s; }
.ins-person-row:last-child { border-bottom:none; }
.ins-person-row:hover { background:rgba(255,255,255,.04); padding-left:6px; }
.ins-person-avatar { width:36px; height:36px; border-radius:50%; object-fit:cover; flex-shrink:0; background:rgba(255,255,255,.08); }
.ins-person-name { flex:1; font-size:13px; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ins-person-meta { font-size:11px; color:var(--text-secondary); margin-top:1px; }
.ins-person-badge { flex-shrink:0; background:rgba(239,68,68,.1); border:1px solid rgba(239,68,68,.25); color:#fca5a5; border-radius:20px; font-size:11px; font-weight:700; padding:2px 9px; }
.ins-guest-row { display:flex; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid rgba(255,255,255,.04); font-size:12px; }
.ins-guest-row:last-child { border-bottom:none; }
.ins-guest-flag-big { font-size:22px; flex-shrink:0; }
/* Download history entries */
.ins-dl-hist-row { display:flex; align-items:center; gap:10px; padding:9px 12px; border-radius:8px; background:rgba(255,255,255,.03); margin-bottom:6px; font-size:12px; }
.ins-dl-hist-num { font-size:11px; font-weight:700; color:var(--text-secondary); min-width:18px; text-align:center; }
.ins-dl-hist-type { font-size:10px; font-weight:700; text-transform:uppercase; padding:2px 8px; border-radius:6px; flex-shrink:0; }
.ins-dl-hist-type.video { background:rgba(239,68,68,.12); color:#fca5a5; }
.ins-dl-hist-type.mp3 { background:rgba(96,165,250,.1); color:#93c5fd; }
.ins-dl-hist-time { flex:1; color:var(--text-secondary); }
.ins-dl-hist-flag { font-size:15px; flex-shrink:0; }
/* Loading state inside modal */
.ins-modal-loading { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:40px 0; gap:10px; color:var(--text-secondary); font-size:13px; }
.ins-modal-spin { width:28px; height:28px; border:3px solid rgba(255,255,255,.1); border-top-color:#ef4444; border-radius:50%; animation:spin .7s linear infinite; }
@keyframes spin { to{transform:rotate(360deg)} }
</style>
{{-- Panel: placed inside .vdb-wrap right after the About panel --}}
<div class="vdb-panel" id="vdb-insights">
<div id="insightsContent">
<div class="ins-skeleton">
<div class="ins-skel-row" style="width:60%;height:18px;"></div>
<div class="ins-skel-row" style="width:100%;height:80px;"></div>
<div class="ins-skel-row" style="width:80%;"></div>
<div class="ins-skel-row" style="width:90%;"></div>
<div class="ins-skel-row" style="width:70%;"></div>
</div>
</div>
</div>
{{-- Modal (position:fixed, nesting doesn't matter) --}}
<div id="insModalBackdrop" class="ins-modal-backdrop" style="display:none;" onclick="if(event.target===this)closeInsModal()">
<div class="ins-modal" onclick="event.stopPropagation()">
<div class="ins-modal-head">
<span class="ins-modal-icon" id="insModalIcon"></span>
<div class="ins-modal-titles">
<div class="ins-modal-title" id="insModalTitle"></div>
<div class="ins-modal-subtitle" id="insModalSubtitle"></div>
</div>
<button class="ins-modal-close" onclick="closeInsModal()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="ins-modal-body" id="insModalBody"></div>
</div>
</div>
<script>
window._insLoaded = false;
window._insData = null;
// ── Helpers ────────────────────────────────────────────
const _cflag = code => (!code||code.length!==2) ? '🌐' : code.toUpperCase().split('').map(c=>String.fromCodePoint(0x1F1E6+c.charCodeAt(0)-65)).join('');
const _fmt = n => { n=n??0; if(n>=1e6) return (n/1e6).toFixed(1).replace(/\.0$/,'')+'M'; if(n>=1e3) return (n/1e3).toFixed(1).replace(/\.0$/,'')+'K'; return String(n); };
const _fmtH = h => (h===null||h===undefined) ? '—' : (h%12||12)+':00 '+(h>=12?'PM':'AM');
const _ago = s => { const d=Math.floor((Date.now()-new Date(s))/1000); if(d<60) return d+'s ago'; if(d<3600) return Math.floor(d/60)+'m ago'; if(d<86400) return Math.floor(d/3600)+'h ago'; return Math.floor(d/86400)+'d ago'; };
const _fmtDt = s => new Date(s).toLocaleDateString('en-US',{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
// ── Modal helpers ──────────────────────────────────────
function _openModal(icon, title, subtitle, html) {
document.getElementById('insModalIcon').textContent = icon;
document.getElementById('insModalTitle').textContent = title;
document.getElementById('insModalSubtitle').textContent = subtitle || '';
document.getElementById('insModalBody').innerHTML = html;
document.getElementById('insModalBackdrop').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function _modalLoading(icon, title, subtitle) {
_openModal(icon, title, subtitle,
`<div class="ins-modal-loading"><div class="ins-modal-spin"></div>Loading details…</div>`);
}
function closeInsModal() {
document.getElementById('insModalBackdrop').style.display = 'none';
document.body.style.overflow = '';
}
document.addEventListener('keydown', e => { if(e.key==='Escape') closeInsModal(); });
// ── Person list HTML (shared) ──────────────────────────
function _personListHtml(users, emptyMsg) {
if (!users || !users.length) return `<p style="font-size:13px;color:var(--text-secondary);margin:0;">${emptyMsg}</p>`;
return users.map((u,i) => `
<div class="ins-person-row" onclick="window.location.href='/channel/${u.channel||u.id}'" title="View ${u.name}'s profile">
<div style="font-size:11px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<img src="${u.avatar}" alt="${u.name}" class="ins-person-avatar">
<div style="flex:1;min-width:0;">
<div class="ins-person-name">${u.name}</div>
<div class="ins-person-meta">Last seen ${_ago(u.last_at)}</div>
</div>
<div class="ins-person-badge">👁 ${u.count}×</div>
</div>`).join('');
}
// ── Mini bar chart HTML (shared) ───────────────────────
function _miniBarChart(daily) {
const max = Math.max(...daily.map(d=>d.count), 1);
const today = new Date().toLocaleDateString('en-US',{month:'short',day:'numeric'});
return daily.map(d => {
const h = Math.round((d.count/max)*56);
const isT = d.label === today;
return `<div class="ins-bar-col">
<div class="ins-bar${isT?' ins-bar-today':''}" style="height:${Math.max(h,2)}px;" title="${d.label}: ${d.count}"></div>
<div class="ins-bar-label">${d.short}</div>
</div>`;
}).join('');
}
// ── Hourly chart HTML ──────────────────────────────────
function _hourlyChart(hourly) {
const max = Math.max(...hourly.map(h=>h.count), 1);
const peak = hourly.reduce((a,b)=>b.count>a.count?b:a, hourly[0]);
const bars = hourly.map(h => {
const ht = Math.round((h.count/max)*54);
const isPk = h.hour === peak.hour && peak.count > 0;
return `<div class="ins-hourly-col">
<div class="ins-hourly-bar${isPk?' peak':''}" style="height:${Math.max(ht,2)}px;" title="${h.label}: ${h.count}"></div>
<div class="ins-hourly-lbl">${h.hour%6===0?h.label:''}</div>
</div>`;
}).join('');
const peakH = peak.count > 0
? `<div style="margin-top:8px;"><span class="ins-peak-badge"><i class="bi bi-clock-fill"></i> Peak ${_fmtH(peak.hour)} · ${peak.count} views</span></div>`
: '';
return `<div class="ins-hourly-chart">${bars}</div>${peakH}`;
}
// ══════════════════════════════════════════════════════
// LOAD MAIN INSIGHTS
// ══════════════════════════════════════════════════════
function loadInsights() {
fetch('{{ route('videos.insights', $video) }}', { headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'} })
.then(r=>{ if(!r.ok) throw r; return r.json(); })
.then(d => { window._insLoaded=true; window._insData=d; renderInsights(d); })
.catch(() => {
document.getElementById('insightsContent').innerHTML =
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load insights. Try again later.</p>';
});
}
function renderInsights(d) {
const weekBadge = d.week_change>0
? `<span class="ins-card-sub up">↑ ${d.week_change}% vs last week</span>`
: d.week_change<0
? `<span class="ins-card-sub down">↓ ${Math.abs(d.week_change)}% vs last week</span>`
: `<span class="ins-card-sub neu">— same as last week</span>`;
const dlSub = (d.dl_video||d.dl_mp3)
? `<span class="ins-card-sub neu">🎬 ${_fmt(d.dl_video)} · 🎵 ${_fmt(d.dl_mp3)}</span>`
: `<span class="ins-card-sub neu">no downloads yet</span>`;
// ── Stat cards (open top-level modal on click) ──────
const cards = `
<p style="font-size:11px;color:var(--text-secondary);margin:0 0 8px;display:flex;align-items:center;gap:5px;">
<i class="bi bi-cursor-fill"></i> Tap any card, chart bar, or country for a detailed breakdown
</p>
<div class="ins-grid">
<div class="ins-card" onclick="openInsModal_views()">
<div class="ins-card-icon">👁️</div><div class="ins-card-val">${_fmt(d.total_views)}</div>
<div class="ins-card-label">Total Views</div>${weekBadge}
</div>
<div class="ins-card" onclick="openInsModal_viewers()">
<div class="ins-card-icon">👤</div><div class="ins-card-val">${_fmt(d.unique_viewers)}</div>
<div class="ins-card-label">Unique Viewers</div>
<span class="ins-card-sub neu">${_fmt(d.views_today)} today</span>
</div>
<div class="ins-card" onclick="openInsModal_downloads()">
<div class="ins-card-icon">⬇️</div><div class="ins-card-val">${_fmt(d.downloads)}</div>
<div class="ins-card-label">Downloads</div>${dlSub}
</div>
<div class="ins-card" onclick="openInsModal_shares()">
<div class="ins-card-icon">🔗</div><div class="ins-card-val">${_fmt(d.shares)}</div>
<div class="ins-card-label">Share Reach</div>
<span class="ins-card-sub neu">${_fmt(d.share_links)} link${d.share_links===1?'':'s'} created</span>
</div>
</div>`;
// ── Bar chart (each bar clickable) ──────────────────
const maxV = Math.max(...d.daily.map(x=>x.count),1);
const todayL = new Date().toLocaleDateString('en-US',{month:'short',day:'numeric'});
const bars = d.daily.map(day => {
const h = Math.round((day.count/maxV)*72);
const isT = day.label===todayL;
const tip = `${day.label}: ${day.count} views`;
return `<div class="ins-bar-col">
<div class="ins-bar clickable${isT?' ins-bar-today':''}" style="height:${Math.max(h,2)}px;"
title="${tip}" onclick="openDayDetail('${day.date}','${day.label}',${day.count})"></div>
<div class="ins-bar-label">${day.short}</div>
</div>`;
}).join('');
const peakHtml = d.peak_hour!==null
? `<div style="margin-top:10px;"><span class="ins-peak-badge"><i class="bi bi-clock-fill"></i> Peak hour: ${_fmtH(d.peak_hour)}</span></div>` : '';
const chartSection = `
<div>
<div class="ins-section-title"><i class="bi bi-graph-up"></i> Views — last 14 days <span style="font-size:10px;opacity:.5;font-weight:400;text-transform:none;">(tap bar for day detail)</span></div>
<div class="ins-chart">${bars}</div>
<div style="font-size:11px;color:var(--text-secondary);text-align:right;">${d.views_this_week} views this week</div>
${peakHtml}
</div>`;
// ── Countries (each row clickable) ──────────────────
let countriesHtml = '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data yet.</p>';
if (d.countries && d.countries.length) {
countriesHtml = d.countries.map(c => `
<div class="ins-country-row" onclick="openCountryDetail('${c.code}','${c.name}',${c.count})" title="View ${c.name||c.code} details">
<div class="ins-country-flag">${_cflag(c.code)}</div>
<div class="ins-country-name">${c.name||c.code}</div>
<div class="ins-country-bar-wrap"><div class="ins-country-bar" style="width:${c.pct}%;"></div></div>
<div class="ins-country-pct">${c.pct}%</div>
<div class="ins-country-cnt">${_fmt(c.count)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;"></i>
</div>`).join('');
}
// ── Viewers section ─────────────────────────────────
const topViewerRows = (d.top_viewers || []).map((u, i) => `
<div class="ins-dl-user-row" onclick="window.location.href='/channel/${u.channel||u.id}'" title="View ${u.name}'s profile">
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<img src="${u.avatar}" alt="${u.name}" class="ins-dl-avatar">
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${u.name}</div>
<div class="ins-dl-user-meta">Last seen ${_ago(u.last_at)}</div>
</div>
<div class="ins-dl-count-badge">👁 ${u.count}×</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`).join('');
const recentViewerRows = (d.recent_viewers || []).map(r => {
const avatarSrc = r.user_avatar || `https://i.pravatar.cc/150?u=guest-${r.country||'unknown'}`;
const clickAttr = r.user_id ? `onclick="window.location.href='/channel/${r.user_channel||r.user_id}'" style="cursor:pointer;" title="View ${r.user_name}'s profile"` : '';
return `<div class="ins-recent-row" ${clickAttr}>
<img src="${avatarSrc}" class="ins-recent-avatar" alt="${r.user_name}">
<div class="ins-recent-name">${r.user_name}</div>
<span class="ins-recent-flag">${_cflag(r.country)}</span>
<span class="ins-recent-time">${_ago(r.at)}</span>
</div>`;
}).join('');
const viewersHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-dl-header">
<div class="ins-section-title" style="margin:0;"><i class="bi bi-people-fill"></i> Viewers — ${_fmt(d.unique_viewers)} registered · ${_fmt(d.guest_views||0)} guest</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">Top Viewers</div>
${topViewerRows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No registered viewers yet.</p>'}
</div>
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">Recent Activity</div>
${recentViewerRows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No views yet.</p>'}
</div>
</div>
</div>`;
// ── Downloads section ───────────────────────────────
let dlHtml = '';
if (d.downloads===0) {
dlHtml = `<div style="margin-top:20px;"><div class="ins-section-title"><i class="bi bi-download"></i> Downloads</div>
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No downloads yet.</p></div>`;
} else {
const pills = [
d.dl_video ? `<span class="ins-dl-pill video"><i class="bi bi-film"></i> ${_fmt(d.dl_video)} video</span>` : '',
d.dl_mp3 ? `<span class="ins-dl-pill mp3"><i class="bi bi-music-note"></i> ${_fmt(d.dl_mp3)} MP3</span>` : '',
d.dl_guests ? `<span class="ins-dl-pill guest"><i class="bi bi-person"></i> ${_fmt(d.dl_guests)} guest</span>` : '',
].filter(Boolean).join('');
const usersRows = d.dl_users && d.dl_users.length
? d.dl_users.map((u,i) => `
<div class="ins-dl-user-row" onclick="openDownloaderHistory(${u.id},'${u.name.replace(/'/g,"\\'")}','${u.avatar}')" title="See full download history">
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<img src="${u.avatar}" alt="${u.name}" class="ins-dl-avatar">
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${u.name}</div>
<div class="ins-dl-user-meta">Last: ${_ago(u.last_at)}</div>
</div>
<div class="ins-dl-count-badge">⬇️ ${u.count}×</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`).join('')
: '<p style="font-size:13px;color:var(--text-secondary);margin:0;">Guests only so far.</p>';
const recentRows = (d.dl_recent||[]).map(r => {
const avatarSrc = r.user_avatar || `https://i.pravatar.cc/150?u=guest-${r.country||'unknown'}`;
const av = `<img src="${avatarSrc}" class="ins-recent-avatar" alt="${r.user_name}">`;
return `<div class="ins-recent-row">${av}
<div class="ins-recent-name">${r.user_name}</div>
<span class="ins-recent-flag">${_cflag(r.country)}</span>
<span class="ins-recent-type ${r.type}">${r.type.toUpperCase()}</span>
<span class="ins-recent-time">${_ago(r.at)}</span>
</div>`;
}).join('');
dlHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-dl-header">
<div class="ins-section-title" style="margin:0;"><i class="bi bi-download"></i> Downloads — ${_fmt(d.downloads)} total</div>
<div class="ins-dl-type-pills">${pills}</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">
Top Downloaders <span style="font-weight:400;opacity:.5;">(tap to see history)</span>
</div>
${usersRows}
</div>
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">Recent Activity</div>
${recentRows}
</div>
</div>
</div>`;
}
// ── Gender breakdown ────────────────────────────────
let genderHtml;
if (d.genders && d.genders.length > 0) {
const gColors = { male: '#4a9eff', female: '#ff6eb0' };
const gSymbols = { male: '♂', female: '♀' };
genderHtml = d.genders.map(g => {
const color = gColors[g.gender] || '#aaa';
const sym = gSymbols[g.gender] || '⚧';
return `<div class="ins-demo-row">
<span class="ins-demo-sym" style="color:${color}">${sym}</span>
<span class="ins-demo-label">${g.gender.charAt(0).toUpperCase()+g.gender.slice(1)}</span>
<div class="ins-demo-bar-wrap"><div class="ins-demo-bar" style="width:${g.pct}%;background:${color}"></div></div>
<span class="ins-demo-pct">${g.pct}%</span>
<span class="ins-demo-cnt">${_fmt(g.count)}</span>
</div>`;
}).join('');
} else {
genderHtml = `<p style="font-size:13px;color:var(--text-secondary);margin:0;">No gender data yet — viewers need a profile to appear here.</p>`;
}
// ── Age group breakdown ──────────────────────────────
let ageHtml;
if (d.age_groups && d.age_groups.length > 0) {
const maxAge = Math.max(...d.age_groups.map(g => g.count), 1);
ageHtml = d.age_groups.map(g => {
const barW = Math.round(g.count / maxAge * 100);
return `<div class="ins-demo-row">
<span class="ins-demo-label ins-age-label">${g.label}</span>
<div class="ins-demo-bar-wrap"><div class="ins-demo-bar" style="width:${barW}%;background:var(--brand-red,#e61e1e)"></div></div>
<span class="ins-demo-pct">${g.pct}%</span>
<span class="ins-demo-cnt">${_fmt(g.count)}</span>
</div>`;
}).join('');
} else {
ageHtml = `<p style="font-size:13px;color:var(--text-secondary);margin:0;">No age data yet — viewers need a date of birth on their profile to appear here.</p>`;
}
const demographicsHtml = `
<div style="margin-top:18px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-body">
<div>
<div class="ins-section-title"><i class="bi bi-gender-ambiguous"></i> Viewers by Gender</div>
${genderHtml}
</div>
<div>
<div class="ins-section-title"><i class="bi bi-people-fill"></i> Viewers by Age Group</div>
${ageHtml}
</div>
</div>
</div>`;
// ── Share links breakdown ───────────────────────────
let shareHtml = '';
if (d.share_links > 0) {
const linkRows = (d.share_breakdown || []).map((s, i) => `
<div class="ins-dl-user-row" style="cursor:default;">
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${s.sharer}</div>
<div class="ins-dl-user-meta">${_ago(s.created_at)}</div>
</div>
<div class="ins-dl-count-badge" title="Unique devices that opened this link">👁️ ${_fmt(s.reach)}</div>
</div>`).join('');
shareHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;flex-wrap:wrap;gap:8px;">
<div class="ins-section-title" style="margin:0;"><i class="bi bi-share"></i> Share Links — ${_fmt(d.share_links)} created · ${_fmt(d.shares)} unique devices reached</div>
</div>
<p style="font-size:11px;color:var(--text-secondary);margin:0 0 10px;line-height:1.5;">Each sharer gets a unique link. The same device opening the same link is counted only once — VPN IP changes don't affect the count.</p>
${linkRows}
</div>`;
} else {
shareHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-section-title"><i class="bi bi-share"></i> Share Links</div>
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No share links created yet. Tap the Share button to generate a unique tracked link.</p>
</div>`;
}
document.getElementById('insightsContent').innerHTML =
cards +
`<div class="ins-body">${chartSection}
<div>
<div class="ins-section-title"><i class="bi bi-globe2"></i> Audience by Country <span style="font-size:10px;opacity:.5;font-weight:400;text-transform:none;">(tap for viewer breakdown)</span></div>
${countriesHtml}
</div></div>` + viewersHtml + dlHtml + shareHtml + demographicsHtml;
}
// ══════════════════════════════════════════════════════
// STAT CARD MODALS (top-level summaries)
// ══════════════════════════════════════════════════════
function openInsModal_views() {
const d = window._insData; if(!d) return;
const changeColor = d.week_change>0?'#4ade80':d.week_change<0?'#f87171':'var(--text-secondary)';
const changeLabel = d.week_change>0?`↑ ${d.week_change}%`:d.week_change<0?`↓ ${Math.abs(d.week_change)}%`:'— 0%';
_openModal('👁️','View Analytics','All-time stats',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.total_views)}</div>
<div class="ins-modal-hero-label">total views</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-calendar-day"></i> Today</span><span class="ins-modal-stat-val">${_fmt(d.views_today)}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-calendar-week"></i> This week</span><span class="ins-modal-stat-val">${_fmt(d.views_this_week)}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-calendar2-minus"></i> Last week</span><span class="ins-modal-stat-val">${_fmt(d.views_last_week)}</span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;">
<span class="ins-modal-stat-lbl"><i class="bi bi-graph-up-arrow"></i> Week over week</span>
<span class="ins-modal-stat-val" style="color:${changeColor};">${changeLabel}</span>
</div>
<div class="ins-modal-section"><i class="bi bi-graph-up"></i> Last 14 days — tap a bar to see that day</div>
<div class="ins-chart" style="height:72px;">${window._insData.daily.map(day=>{
const h=Math.round((day.count/Math.max(...window._insData.daily.map(x=>x.count),1))*68);
const isT=day.label===new Date().toLocaleDateString('en-US',{month:'short',day:'numeric'});
return `<div class="ins-bar-col"><div class="ins-bar clickable${isT?' ins-bar-today':''}" style="height:${Math.max(h,2)}px;" title="${day.label}: ${day.count}" onclick="closeInsModal();setTimeout(()=>openDayDetail('${day.date}','${day.label}',${day.count}),180)"></div><div class="ins-bar-label">${day.short}</div></div>`;
}).join('')}</div>
${d.peak_hour!==null?`<div style="margin-top:10px;"><span class="ins-peak-badge"><i class="bi bi-clock-fill"></i> Peak hour: ${_fmtH(d.peak_hour)}</span></div>`:''}`);
}
function openInsModal_viewers() {
const d = window._insData; if(!d) return;
const guestV = Math.max(0, d.total_views - d.unique_viewers);
const mPct = d.total_views>0 ? Math.round(d.unique_viewers/d.total_views*100) : 0;
_openModal('👤','Viewer Breakdown','Registered vs anonymous',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.unique_viewers)}</div>
<div class="ins-modal-hero-label">unique registered viewers</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-eye"></i> Total views</span><span class="ins-modal-stat-val">${_fmt(d.total_views)}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Registered</span><span class="ins-modal-stat-val">${mPct}% <span style="color:var(--text-secondary);font-weight:400;">(${_fmt(d.unique_viewers)} views)</span></span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;"><span class="ins-modal-stat-lbl"><i class="bi bi-person-dash"></i> Anonymous</span><span class="ins-modal-stat-val">${100-mPct}% <span style="color:var(--text-secondary);font-weight:400;">(${_fmt(guestV)} views)</span></span></div>
<div class="ins-modal-section"><i class="bi bi-globe2"></i> Top countries — tap to see viewers</div>
${(d.countries||[]).slice(0,5).map(c=>`
<div class="ins-country-row" onclick="closeInsModal();setTimeout(()=>openCountryDetail('${c.code}','${c.name}',${c.count}),180)" style="cursor:pointer;">
<div class="ins-country-flag">${_cflag(c.code)}</div>
<div class="ins-country-name">${c.name||c.code}</div>
<div class="ins-country-bar-wrap"><div class="ins-country-bar" style="width:${c.pct}%;"></div></div>
<div class="ins-country-pct">${c.pct}%</div>
<div class="ins-country-cnt">${_fmt(c.count)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);"></i>
</div>`).join('')||'<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data.</p>'}`);
}
function openInsModal_downloads() {
const d = window._insData; if(!d) return;
if (d.downloads===0) {
_openModal('⬇️','Downloads','No downloads yet',`<div style="text-align:center;padding:30px 0;"><div style="font-size:48px;margin-bottom:12px;">📭</div><p style="color:var(--text-secondary);font-size:14px;margin:0;">No one has downloaded this video yet.</p></div>`);
return;
}
const engRate = d.total_views>0 ? ((d.downloads/d.total_views)*100).toFixed(1) : '0.0';
_openModal('⬇️','Download Summary',`${_fmt(d.downloads)} total downloads`,`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.downloads)}</div>
<div class="ins-modal-hero-label">total downloads</div>
</div>
<div style="display:flex;gap:8px;margin-bottom:18px;flex-wrap:wrap;">
${d.dl_video ? `<span class="ins-dl-pill video"><i class="bi bi-film"></i> ${_fmt(d.dl_video)} video</span>` : ''}
${d.dl_mp3 ? `<span class="ins-dl-pill mp3"><i class="bi bi-music-note"></i> ${_fmt(d.dl_mp3)} MP3</span>` : ''}
${d.dl_guests ? `<span class="ins-dl-pill guest"><i class="bi bi-person"></i> ${_fmt(d.dl_guests)} guest</span>` : ''}
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-eye"></i> Download rate</span><span class="ins-modal-stat-val">${engRate}% of viewers</span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;"><span class="ins-modal-stat-lbl"><i class="bi bi-people"></i> Unique downloaders</span><span class="ins-modal-stat-val">${_fmt((d.dl_users||[]).length + (d.dl_guests>0?1:0))}</span></div>
${(d.dl_users||[]).length ? `
<div class="ins-modal-section"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Registered — tap for full history</div>
${d.dl_users.map((u,i)=>`
<div class="ins-person-row" style="cursor:pointer;" onclick="closeInsModal();setTimeout(()=>openDownloaderHistory(${u.id},'${u.name.replace(/'/g,"\\'")}','${u.avatar}'),180)">
<div style="font-size:11px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<img src="${u.avatar}" class="ins-person-avatar" alt="${u.name}">
<div style="flex:1;min-width:0;"><div class="ins-person-name">${u.name}</div><div class="ins-person-meta">Last: ${_ago(u.last_at)}</div></div>
<div class="ins-person-badge">⬇️ ${u.count}×</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);margin-left:6px;flex-shrink:0;"></i>
</div>`).join('')}` : ''}
`);
}
function openInsModal_shares() {
const d = window._insData; if(!d) return;
const engRate = d.total_views>0 ? (((d.shares)+d.likes)/d.total_views*100).toFixed(1) : '0.0';
const linkRows = (d.share_breakdown||[]).length
? (d.share_breakdown||[]).map((s,i) => `
<div class="ins-dl-user-row" style="cursor:default;">
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${s.sharer}</div>
<div class="ins-dl-user-meta">Created: ${_ago(s.created_at)}</div>
</div>
<div class="ins-dl-count-badge">👁️ ${_fmt(s.reach)}</div>
</div>`).join('')
: '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No share links created yet.</p>';
_openModal('🔗','Share Reach','Unique devices that opened your share links',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.shares)}</div>
<div class="ins-modal-hero-label">unique devices reached</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-link-45deg"></i> Links created</span><span class="ins-modal-stat-val">${_fmt(d.share_links)}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-hand-thumbs-up-fill" style="color:#ef4444;"></i> Likes</span><span class="ins-modal-stat-val">❤️ ${_fmt(d.likes)}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-activity"></i> Engagement rate</span><span class="ins-modal-stat-val">${engRate}%</span></div>
<div class="ins-modal-section" style="margin-top:16px;"><i class="bi bi-people-fill"></i> Share links — top by reach</div>
${linkRows}
<p style="font-size:11px;color:var(--text-secondary);margin:12px 0 0;line-height:1.5;">Each person who shares gets a unique link. Same device opening the same link counts only once.</p>`);
}
// ══════════════════════════════════════════════════════
// DRILL-DOWN: COUNTRY
// ══════════════════════════════════════════════════════
function openCountryDetail(code, name, totalCount) {
const flag = _cflag(code);
_modalLoading(flag, name||code, `${_fmt(totalCount)} views from this country`);
fetch(`{{ url('/videos/' . $video->getRouteKey() . '/insights/country') }}/${code}`, {
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
})
.then(r=>{ if(!r.ok) throw r; return r.json(); })
.then(d => {
const regHtml = _personListHtml(d.registered_users, 'No registered viewers yet from this country.');
const miniChart = _miniBarChart(d.daily);
document.getElementById('insModalBody').innerHTML = `
<div class="ins-modal-hero">
<div class="ins-modal-hero-flag">${flag}</div>
<div class="ins-modal-hero-num">${_fmt(d.total_views)}</div>
<div class="ins-modal-hero-label">total views from ${d.country_name||code}</div>
</div>
<div class="ins-modal-stat">
<span class="ins-modal-stat-lbl"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Registered viewers</span>
<span class="ins-modal-stat-val">${_fmt((d.registered_users||[]).length)}</span>
</div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;">
<span class="ins-modal-stat-lbl"><i class="bi bi-person-dash"></i> Anonymous views</span>
<span class="ins-modal-stat-val">${_fmt(d.guest_count)}</span>
</div>
<div class="ins-modal-section"><i class="bi bi-graph-up"></i> 14-day trend from ${d.country_name||code}</div>
<div class="ins-chart" style="height:60px;">${miniChart}</div>
${(d.registered_users||[]).length ? `
<div class="ins-modal-section"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Registered viewers</div>
${regHtml}` : `
<div class="ins-modal-section"><i class="bi bi-people"></i> Viewers</div>
<p style="font-size:13px;color:var(--text-secondary);margin:0;">
All ${_fmt(d.total_views)} views from ${d.country_name||code} were anonymous.
</p>`}`;
document.getElementById('insModalTitle').textContent = d.country_name || code;
document.getElementById('insModalSubtitle').textContent = `${_fmt(d.total_views)} views from this country`;
})
.catch(() => {
document.getElementById('insModalBody').innerHTML =
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load country details.</p>';
});
}
// ══════════════════════════════════════════════════════
// DRILL-DOWN: SPECIFIC DAY
// ══════════════════════════════════════════════════════
function openDayDetail(date, label, totalCount) {
_modalLoading('📅', label, `${_fmt(totalCount)} views on this day`);
fetch(`{{ url('/videos/' . $video->getRouteKey() . '/insights/day') }}/${date}`, {
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
})
.then(r=>{ if(!r.ok) throw r; return r.json(); })
.then(d => {
const hourlyHtml = _hourlyChart(d.hourly);
const regHtml = _personListHtml(d.registered_users, 'No registered viewers on this day.');
const topCountries = (d.countries||[]).map(c=>`
<div class="ins-country-row" style="cursor:pointer;" onclick="closeInsModal();setTimeout(()=>openCountryDetail('${c.code}','${c.name}',${c.count}),180)">
<div class="ins-country-flag">${_cflag(c.code)}</div>
<div class="ins-country-name">${c.name||c.code}</div>
<div class="ins-country-cnt" style="margin-left:auto;">${_fmt(c.count)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);"></i>
</div>`).join('') || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data.</p>';
document.getElementById('insModalIcon').textContent = '📅';
document.getElementById('insModalTitle').textContent = `${d.day_of_week}, ${d.date}`;
document.getElementById('insModalSubtitle').textContent = `${_fmt(d.total_views)} views on this day`;
document.getElementById('insModalBody').innerHTML = `
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.total_views)}</div>
<div class="ins-modal-hero-label">views on ${d.day_of_week}</div>
</div>
<div class="ins-modal-stat">
<span class="ins-modal-stat-lbl"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Registered</span>
<span class="ins-modal-stat-val">${_fmt((d.registered_users||[]).length)} viewers (${_fmt(d.total_views - d.guest_count)} views)</span>
</div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;">
<span class="ins-modal-stat-lbl"><i class="bi bi-person-dash"></i> Anonymous</span>
<span class="ins-modal-stat-val">${_fmt(d.guest_count)} views</span>
</div>
<div class="ins-modal-section"><i class="bi bi-clock"></i> Views by Hour</div>
${hourlyHtml}
${(d.registered_users||[]).length ? `
<div class="ins-modal-section" style="margin-top:18px;"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Who watched</div>
${regHtml}` : ''}
<div class="ins-modal-section" style="margin-top:18px;"><i class="bi bi-globe2"></i> Top countries — tap to explore</div>
${topCountries}`;
})
.catch(() => {
document.getElementById('insModalBody').innerHTML =
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load day details.</p>';
});
}
// ══════════════════════════════════════════════════════
// DRILL-DOWN: DOWNLOADER HISTORY
// ══════════════════════════════════════════════════════
function openDownloaderHistory(userId, userName, userAvatar) {
_modalLoading('⬇️', userName, 'Loading download history…');
document.getElementById('insModalIcon').innerHTML = `<img src="${userAvatar}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;">`;
fetch(`{{ url('/videos/' . $video->getRouteKey() . '/insights/downloader') }}/${userId}`, {
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
})
.then(r=>{ if(!r.ok) throw r; return r.json(); })
.then(d => {
document.getElementById('insModalIcon').innerHTML = `<img src="${d.user.avatar}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;">`;
document.getElementById('insModalTitle').textContent = d.user.name;
document.getElementById('insModalSubtitle').textContent = `${d.total} download${d.total!==1?'s':''} of this video`;
const rows = d.records.map((r,i) => `
<div class="ins-dl-hist-row">
<div class="ins-dl-hist-num">${i+1}</div>
<span class="ins-dl-hist-type ${r.type}">${r.type.toUpperCase()}</span>
<div class="ins-dl-hist-time">${_fmtDt(r.at)}</div>
<div class="ins-dl-hist-flag" title="${r.country_name||r.country||'Unknown'}">${_cflag(r.country)}</div>
</div>`).join('');
document.getElementById('insModalBody').innerHTML = `
<div style="display:flex;align-items:center;gap:12px;padding:4px 0 20px;">
<img src="${d.user.avatar}" style="width:52px;height:52px;border-radius:50%;object-fit:cover;border:2px solid var(--border-color);">
<div>
<div style="font-size:16px;font-weight:700;color:var(--text-primary);">${d.user.name}</div>
<div style="font-size:13px;color:var(--text-secondary);margin-top:2px;">
${d.total} download${d.total!==1?'s':''} of this video
</div>
</div>
</div>
<div class="ins-modal-section"><i class="bi bi-clock-history"></i> Download History</div>
${rows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No records found.</p>'}`;
})
.catch(() => {
document.getElementById('insModalBody').innerHTML =
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load download history.</p>';
});
}
</script>
@endif
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,47 @@
<x-emails.layout subject="Confirm disabling Two-Factor Authentication">
{{-- Icon --}}
<div style="width:64px;height:64px;border-radius:50%;background:rgba(230,30,30,.12);border:1px solid rgba(230,30,30,.25);margin:0 auto 24px;text-align:center;line-height:64px;font-size:28px;">&#x1F6E1;&#xFE0F;</div>
<h1 class="email-title">Disable Two-Factor Authentication?</h1>
<p class="email-subtitle">Someone requested to turn off 2FA on your account.</p>
<p class="email-text">Hi <strong style="color:#f1f1f1;">{{ $user->name }}</strong>,</p>
<p class="email-text">
A request was made to <strong style="color:#e61e1e;">disable two-factor authentication</strong> on your
<strong style="color:#f1f1f1;">TAKEONE</strong> account. If this was you, click the button below to confirm.
</p>
<div class="email-infobox" style="background:rgba(230,30,30,.08);border-color:rgba(230,30,30,.3);margin-bottom:28px;">
<p style="margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;font-size:13px;color:#cc4444;line-height:1.6;">
&#9888;&nbsp; <strong>Warning:</strong> Disabling 2FA will make your account less secure.
Anyone who gains access to your password could log in without a second step.
</p>
</div>
{{-- CTA --}}
<div class="email-btn-wrap">
<a href="{{ $confirmUrl }}" class="email-btn" style="background:#e61e1e;">Yes, disable 2FA</a>
</div>
{{-- Expiry notice --}}
<div class="email-infobox" style="text-align:center;">
<p style="margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;font-size:13px;color:#666;line-height:1.6;">
&#9201;&nbsp; This link expires in <strong style="color:#888;">15 minutes</strong>.
If it expires, go to your channel settings and submit the form again.
</p>
</div>
<hr class="email-divider">
<p class="email-note">
<strong style="color:#f1f1f1;">Didn't request this?</strong>
Do not click the button above. Your 2FA remains active. Consider changing your password immediately
as someone may have access to your account.
</p>
<p class="email-note" style="margin-top:12px;">
If the button above doesn't work, copy and paste this link into your browser:<br>
<a href="{{ $confirmUrl }}" style="color:#e61e1e;word-break:break-all;">{{ $confirmUrl }}</a>
</p>
</x-emails.layout>
+218
View File
@@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{ $subject ?? config('app.name') }}</title>
<style>
/* Reset */
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
table, td { mso-table-lspace: 0; mso-table-rspace: 0; }
img { -ms-interpolation-mode: bicubic; border: 0; outline: none; text-decoration: none; }
body { margin: 0; padding: 0; background-color: #0f0f0f; width: 100% !important; }
.wrapper { background-color: #0f0f0f; padding: 40px 16px; }
.email-card {
max-width: 600px;
margin: 0 auto;
background: #181818;
border-radius: 16px;
overflow: hidden;
border: 1px solid #2a2a2a;
box-shadow: 0 24px 64px rgba(0,0,0,0.6);
}
/* Header */
.email-header {
background: #0f0f0f;
border-bottom: 1px solid #2a2a2a;
padding: 28px 40px;
text-align: center;
}
.email-header img { height: 36px; display: block; margin: 0 auto; }
/* Body */
.email-body { padding: 40px; }
.email-icon {
width: 64px;
height: 64px;
border-radius: 50%;
margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
}
h1.email-title {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 22px;
font-weight: 700;
color: #f1f1f1;
margin: 0 0 8px;
text-align: center;
line-height: 1.3;
}
p.email-subtitle {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
color: #888;
text-align: center;
margin: 0 0 32px;
line-height: 1.6;
}
p.email-text {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
color: #c8c8c8;
margin: 0 0 20px;
line-height: 1.7;
}
/* CTA Button */
.email-btn-wrap { text-align: center; margin: 32px 0; }
.email-btn {
display: inline-block;
background: #e61e1e;
color: #ffffff !important;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
font-weight: 700;
text-decoration: none;
padding: 14px 36px;
border-radius: 50px;
letter-spacing: 0.3px;
box-shadow: 0 8px 24px rgba(230,30,30,0.35);
}
.email-btn:hover { background: #c91a1a; }
/* Info box */
.email-infobox {
background: #222;
border: 1px solid #2d2d2d;
border-radius: 12px;
padding: 20px 24px;
margin: 24px 0;
}
.email-infobox-label {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #666;
margin: 0 0 12px;
}
.email-inforow {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 10px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.email-inforow:last-child { margin-bottom: 0; }
.email-inforow-key {
font-size: 13px;
color: #666;
min-width: 90px;
flex-shrink: 0;
}
.email-inforow-val {
font-size: 13px;
color: #c8c8c8;
font-weight: 500;
word-break: break-all;
}
/* Divider */
.email-divider {
border: none;
border-top: 1px solid #2a2a2a;
margin: 32px 0;
}
/* Small note */
.email-note {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
color: #555;
text-align: center;
line-height: 1.7;
margin: 0;
}
.email-note a { color: #888; text-decoration: underline; }
/* Footer */
.email-footer {
background: #0f0f0f;
border-top: 1px solid #2a2a2a;
padding: 24px 40px;
text-align: center;
}
.email-footer p {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
color: #444;
margin: 4px 0;
line-height: 1.6;
}
.email-footer a { color: #666; text-decoration: none; }
.email-footer a:hover { color: #e61e1e; }
/* Thumbnail */
.email-thumb-wrap { margin: 0 0 28px; text-align: center; }
.email-thumb-wrap a { display: block; }
.email-thumb-wrap img {
width: 100%;
max-width: 520px;
height: auto;
border-radius: 12px;
display: block;
margin: 0 auto;
border: 1px solid #2a2a2a;
}
.email-thumb-placeholder {
width: 100%;
max-width: 520px;
height: 190px;
background: linear-gradient(135deg, #1a1a1a 0%, #2a0808 100%);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
border: 1px solid #2a2a2a;
flex-direction: column;
gap: 10px;
}
.email-thumb-placeholder span {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
font-weight: 600;
color: #888;
}
@media only screen and (max-width: 600px) {
.wrapper { padding: 20px 10px; }
.email-body { padding: 28px 24px; }
.email-header { padding: 22px 24px; }
.email-footer { padding: 20px 24px; }
h1.email-title { font-size: 20px; }
}
</style>
</head>
<body>
<div class="wrapper">
<div class="email-card">
{{-- Header --}}
<div class="email-header">
<img src="{{ config('app.url') }}/storage/images/fullLogo.png" alt="{{ config('app.name') }}">
</div>
{{-- Body --}}
<div class="email-body">
{{ $slot }}
</div>
{{-- Footer --}}
<div class="email-footer">
<p>&copy; {{ date('Y') }} <strong style="color:#666;">TAKEONE</strong> &mdash; Video Platform</p>
<p><a href="{{ config('app.url') }}">{{ config('app.url') }}</a></p>
<p style="margin-top:8px;">This email was sent to you because you have an account on TAKEONE.</p>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,60 @@
<x-emails.layout subject="{{ $uploader->name }} just uploaded a new video">
{{-- Icon --}}
<div style="width:64px;height:64px;border-radius:50%;background:rgba(230,30,30,.12);border:1px solid rgba(230,30,30,.25);margin:0 auto 24px;text-align:center;line-height:64px;font-size:28px;">&#x1F514;</div>
<h1 class="email-title">New video from {{ $uploader->name }}</h1>
<p class="email-subtitle">A channel you subscribed to just posted new content.</p>
{{-- Thumbnail --}}
<div class="email-thumb-wrap">
@if($video->thumbnail)
<a href="{{ route('videos.show', $video) }}">
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}">
</a>
@else
<div class="email-thumb-placeholder">
<span style="font-size:32px;">▶</span>
<span>{{ Str::limit($video->title, 40) }}</span>
</div>
@endif
</div>
{{-- Details box --}}
<div class="email-infobox">
<div class="email-infobox-label">Video Details</div>
<div class="email-inforow">
<span class="email-inforow-key">Title</span>
<span class="email-inforow-val">{{ $video->title }}</span>
</div>
@if($video->description)
<div class="email-inforow">
<span class="email-inforow-key">Description</span>
<span class="email-inforow-val">{{ Str::limit($video->description, 100) }}</span>
</div>
@endif
@if($video->formatted_duration)
<div class="email-inforow">
<span class="email-inforow-key">Duration</span>
<span class="email-inforow-val">{{ $video->formatted_duration }}</span>
</div>
@endif
<div class="email-inforow">
<span class="email-inforow-key">Channel</span>
<span class="email-inforow-val">{{ $uploader->name }}</span>
</div>
</div>
{{-- CTA --}}
<div class="email-btn-wrap">
<a href="{{ route('videos.show', $video) }}" class="email-btn">&#9654;&nbsp; Watch Now</a>
</div>
<hr class="email-divider">
<p class="email-note">
You're receiving this because you subscribed to <strong>{{ $uploader->name }}</strong>'s channel on {{ config('app.name') }}.
Visit <a href="{{ route('channel', $uploader->channel) }}">their channel</a> to manage your subscription.
</p>
</x-emails.layout>
@@ -0,0 +1,38 @@
<x-emails.layout subject="Verify your email address">
{{-- Icon --}}
<div style="width:64px;height:64px;border-radius:50%;background:rgba(230,30,30,.12);border:1px solid rgba(230,30,30,.25);margin:0 auto 24px;text-align:center;line-height:64px;font-size:28px;">&#x2709;&#xFE0F;</div>
<h1 class="email-title">Verify your email address</h1>
<p class="email-subtitle">One quick step before you can start sharing videos.</p>
<p class="email-text">Hi <strong style="color:#f1f1f1;">{{ $userName }}</strong>,</p>
<p class="email-text">
Thanks for signing up for <strong style="color:#f1f1f1;">TAKEONE</strong>!
To activate your account and start uploading and watching videos, please verify your email address by clicking the button below.
</p>
{{-- CTA --}}
<div class="email-btn-wrap">
<a href="{{ $url }}" class="email-btn">Verify Email Address</a>
</div>
{{-- Expiry notice --}}
<div class="email-infobox" style="text-align:center;">
<p style="margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;font-size:13px;color:#666;line-height:1.6;">
&#9201;&nbsp; This link expires in <strong style="color:#888;">60 minutes</strong>.
If it expires, simply sign in and we'll send you a fresh one.
</p>
</div>
<hr class="email-divider">
<p class="email-note">
If you didn't create a TAKEONE account, you can safely ignore this email &mdash; no action is needed.
</p>
<p class="email-note" style="margin-top:12px;">
If the button above doesn't work, copy and paste this link into your browser:<br>
<a href="{{ $url }}" style="color:#e61e1e;word-break:break-all;">{{ $url }}</a>
</p>
</x-emails.layout>
+64 -79
View File
@@ -1,89 +1,74 @@
<!DOCTYPE html>
<html>
<x-emails.layout subject="Your video is live on TAKEONE">
<head>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
{{-- Icon --}}
<div style="width:64px;height:64px;border-radius:50%;background:rgba(230,30,30,.12);border:1px solid rgba(230,30,30,.25);margin:0 auto 24px;text-align:center;line-height:64px;font-size:28px;">&#x1F3AC;</div>
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
<h1 class="email-title">Your video is live!</h1>
<p class="email-subtitle">It's uploading and processing right now — we'll have it ready shortly.</p>
.header {
background: #e61e1e;
color: white;
padding: 20px;
text-align: center;
}
<p class="email-text">Hi <strong style="color:#f1f1f1;">{{ $userName }}</strong>,</p>
<p class="email-text">
Your video <strong style="color:#f1f1f1;">"{{ $video->title }}"</strong> has been received and is now queued for processing.
Once encoding is complete it will be publicly available on your channel.
</p>
.content {
padding: 20px;
background: #f9f9f9;
}
.footer {
text-align: center;
padding: 20px;
color: #666;
font-size: 12px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>TAKEONE</h1>
</div>
<div class="content">
<h2>Video Uploaded Successfully! 🎉</h2>
<p>Hi {{ $userName }},</p>
<p>Your video <strong>"{{ $video->title }}"</strong> has been uploaded successfully!</p>
<p>Your video is now being processed and will be available shortly.</p>
<p>Check out your uploaded video:</p>
<div style="margin: 20px 0; text-align: center;">
@if ($video->thumbnail)
<a href="{{ url('/videos/' . $video->id) }}">
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}"
style="max-width: 100%; height: auto; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.3); display: block; margin: 0 auto;">
</a>
@else
<div
style="width: 100%; height: 200px; background: linear-gradient(45deg, #e61e1e, #ff4757); border-radius: 12px; display: flex; align-items: center; justify-content: center; margin: 0 auto; color: white; font-size: 18px; font-weight: bold; box-shadow: 0 8px 24px rgba(230,30,30,0.4);">
<i class="bi bi-play-circle-fill" style="font-size: 48px; margin-right: 16px;"></i>
{{ Str::limit($video->title, 30) }}
</div>
<p style="margin-top: 12px; color: #666; font-size: 14px;">Thumbnail generating...</p>
@endif
{{-- Thumbnail --}}
<div class="email-thumb-wrap">
@if($video->thumbnail)
<a href="{{ route('videos.show', $video) }}">
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}">
</a>
@else
<div class="email-thumb-placeholder">
<span style="font-size:32px;">▶</span>
<span>{{ Str::limit($video->title, 40) }}</span>
</div>
@endif
</div>
<div style="background: #f0f0f0; padding: 16px; border-radius: 8px; margin: 20px 0;">
<h4 style="margin-top: 0; color: #333;">Video Details:</h4>
<ul style="margin: 0; padding-left: 20px;">
<li><strong>Title:</strong> {{ $video->title }}</li>
<li><strong>Size:</strong> {{ round($video->size / 1024 / 1024, 2) }} MB</li>
<li><strong>Orientation:</strong> {{ $video->orientation ?? 'Horizontal' }}</li>
</ul>
</div>
<p style="text-align: center;">
<a href="{{ url('/videos/' . $video->id) }}"
style="background: #e61e1e; color: white; padding: 12px 24px; text-decoration: none; border-radius: 25px; font-weight: 600; font-size: 16px; display: inline-block; box-shadow: 0 4px 12px rgba(230,30,30,0.4);">▶️
Watch Video</a>
</p>
{{-- Details box --}}
<div class="email-infobox">
<div class="email-infobox-label">Video Details</div>
<div class="email-inforow">
<span class="email-inforow-key">Title</span>
<span class="email-inforow-val">{{ $video->title }}</span>
</div>
<div class="footer">
<p>TAKEONE Video Platform</p>
@if($video->description)
<div class="email-inforow">
<span class="email-inforow-key">Description</span>
<span class="email-inforow-val">{{ Str::limit($video->description, 80) }}</span>
</div>
@endif
<div class="email-inforow">
<span class="email-inforow-key">File size</span>
<span class="email-inforow-val">{{ round($video->size / 1024 / 1024, 2) }} MB</span>
</div>
@if($video->formatted_duration)
<div class="email-inforow">
<span class="email-inforow-key">Duration</span>
<span class="email-inforow-val">{{ $video->formatted_duration }}</span>
</div>
@endif
<div class="email-inforow">
<span class="email-inforow-key">Type</span>
<span class="email-inforow-val" style="text-transform:capitalize;">{{ $video->type ?? 'Generic' }}</span>
</div>
<div class="email-inforow">
<span class="email-inforow-key">Visibility</span>
<span class="email-inforow-val" style="text-transform:capitalize;">{{ $video->visibility ?? 'Public' }}</span>
</div>
</div>
</body>
</html>
{{-- CTA --}}
<div class="email-btn-wrap">
<a href="{{ route('videos.show', $video) }}" class="email-btn">&#9654;&nbsp; Watch Video</a>
</div>
<hr class="email-divider">
<p class="email-note">
Questions? Reply to this email or visit
<a href="{{ config('app.url') }}">{{ config('app.url') }}</a>.
</p>
</x-emails.layout>
File diff suppressed because it is too large. Load diff
+195
View File
@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@yield('title', config('app.name'))</title>
<link rel="icon" type="image/png" href="{{ asset('storage/images/logo.png') }}">
<link rel="apple-touch-icon" href="{{ asset('storage/images/logo.png') }}">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.css">
<style>
:root {
--brand-red: #e61e1e;
--bg-dark: #0f0f0f;
--bg-secondary: #1a1a1a;
--bg-card: #1e1e1e;
--border-color: #2e2e2e;
--text-primary: #f1f1f1;
--text-secondary: #888;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg-dark);
color: var(--text-primary);
font-family: "Inter", "Roboto", "Arial", sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
}
/* Subtle radial glow behind card */
body::before {
content: '';
position: fixed;
inset: 0;
background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(230,30,30,.07) 0%, transparent 70%);
pointer-events: none;
}
/* ── Auth card ─────────────────────────────────────────────────── */
.auth-wrap {
width: 100%;
max-width: 400px;
position: relative;
}
.auth-logo {
text-align: center;
margin-bottom: 32px;
}
.auth-logo a {
display: inline-block;
text-decoration: none;
}
.auth-logo img { height: 36px; }
.auth-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 36px 32px;
box-shadow: 0 24px 64px rgba(0,0,0,.5);
}
.auth-title {
font-size: 22px;
font-weight: 700;
letter-spacing: -.3px;
margin-bottom: 6px;
text-align: center;
}
.auth-subtitle {
font-size: 14px;
color: var(--text-secondary);
text-align: center;
margin-bottom: 28px;
}
/* ── Form elements ─────────────────────────────────────────────── */
.form-group { margin-bottom: 16px; }
.form-label {
display: block;
margin-bottom: 7px;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.form-input {
width: 100%;
background: #121212;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 11px 14px;
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
transition: border-color .2s;
outline: none;
}
.form-input:focus { border-color: var(--brand-red); }
.form-input::placeholder { color: var(--text-secondary); }
.form-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
user-select: none;
}
.form-checkbox input[type="checkbox"] {
width: 16px; height: 16px;
cursor: pointer;
accent-color: var(--brand-red);
flex-shrink: 0;
}
/* ── Primary button ────────────────────────────────────────────── */
.btn-primary {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
background: var(--brand-red);
color: #fff;
border: none;
padding: 12px 24px;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background .2s;
margin-top: 8px;
}
.btn-primary:hover { background: #c91a1a; }
/* ── Error ─────────────────────────────────────────────────────── */
.auth-error {
display: flex;
align-items: flex-start;
gap: 10px;
background: rgba(230,30,30,.1);
border: 1px solid rgba(230,30,30,.35);
color: #f87171;
padding: 12px 14px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 13px;
line-height: 1.5;
}
.auth-error i { flex-shrink: 0; margin-top: 1px; }
/* ── Footer link ───────────────────────────────────────────────── */
.auth-footer {
text-align: center;
margin-top: 24px;
font-size: 13px;
color: var(--text-secondary);
}
.auth-footer a { color: var(--brand-red); text-decoration: none; font-weight: 500; }
.auth-footer a:hover { text-decoration: underline; }
@media (max-width: 480px) {
.auth-card { padding: 28px 20px; border-radius: 12px; }
}
</style>
@yield('extra_styles')
</head>
<body>
<div class="auth-wrap">
<div class="auth-logo">
<a href="{{ route('videos.index') }}">
<img src="{{ asset('storage/images/fullLogo.png') }}" alt="{{ config('app.name') }}">
</a>
</div>
@yield('content')
<div class="auth-footer">@yield('footer_link')</div>
</div>
@yield('scripts')
</body>
</html>
@@ -34,10 +34,10 @@
<input type="text" id="newPlaylistName" placeholder="Playlist name"
style="width: 100%; padding: 12px 14px; margin-bottom: 12px; border: 1px solid #3f3f3f; border-radius: 8px; background: #121212; color: #fff; font-size: 14px; outline: none; transition: border-color 0.2s;">
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button onclick="hideCreatePlaylistInModal()"
style="padding: 8px 16px; background: #3f3f3f; color: #fff; border: none; border-radius: 18px; cursor: pointer; font-size: 14px; font-weight: 500; transition: background 0.2s;">Cancel</button>
<button onclick="createPlaylistFromModal()"
style="padding: 8px 16px; background: #e61e1e; color: #fff; border: none; border-radius: 18px; cursor: pointer; font-size: 14px; font-weight: 500; transition: background 0.2s;">Create</button>
<button onclick="hideCreatePlaylistInModal()" class="action-btn">Cancel</button>
<button onclick="createPlaylistFromModal()" class="action-btn action-btn-primary">
<i class="bi bi-plus-lg"></i> <span>Create</span>
</button>
</div>
</div>
@endauth
@@ -365,7 +365,7 @@
function createPlaylistFromModal() {
const name = document.getElementById('newPlaylistName').value.trim();
if (!name) {
alert('Please enter a playlist name');
showToast('Please enter a playlist name', 'error');
return;
}
@@ -17,11 +17,11 @@
</div>
</div>
<div class="modal-footer" style="border-top: 1px solid var(--border-color); padding: 16px 20px; gap: 8px;">
<button type="button" class="btn" style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); padding: 8px 16px; border-radius: 6px; font-weight: 500;" data-bs-dismiss="modal">
<button type="button" class="action-btn" data-bs-dismiss="modal">
Cancel
</button>
<button type="button" class="btn" style="background: var(--brand-red); color: white; padding: 8px 16px; border-radius: 6px; font-weight: 500; border: none;" onclick="deleteCommentWithModal({{ $commentId }})">
Delete
<button type="button" class="action-btn action-btn-primary" onclick="deleteCommentWithModal({{ $commentId }})">
<i class="bi bi-trash"></i> <span>Delete</span>
</button>
</div>
</div>
@@ -116,15 +116,17 @@
<div class="step-navigation">
<div></div>
<button type="button" class="btn-next" onclick="editNextStep(2)">
Next Step <i class="bi bi-arrow-right"></i>
<button type="button" class="action-btn action-btn-primary btn-next" onclick="editNextStep(2)">
<span>Next Step</span> <i class="bi bi-arrow-right"></i>
</button>
</div>
</div>
<!-- Step 2: Thumbnail -->
<!-- Step 2: Thumbnail / Slides -->
<div class="edit-step-content" data-step="2">
<div class="form-group">
<!-- Standard thumbnail (non-audio) -->
<div id="edit-thumb-section" class="form-group">
<label class="form-label">
<i class="bi bi-camera"></i> Change Thumbnail
</label>
@@ -135,7 +137,7 @@
<i class="bi bi-card-image"></i>
</div>
<p class="dropzone-title">Click to select or drag thumbnail</p>
<p class="dropzone-hint">JPG, PNG, WebP up to 5MB</p>
<p class="dropzone-hint">JPG, PNG, WebP up to 20MB</p>
</div>
<div id="edit-thumbnail-info" class="file-info-modal">
<div class="file-preview thumbnail-preview">
@@ -145,20 +147,42 @@
<p class="filename" id="edit-thumbnail-filename"></p>
<p id="edit-thumbnail-filesize"></p>
</div>
<button type="button" class="btn-remove-file"
onclick="removeEditThumbnail(event)">
<button type="button" class="btn-remove-file" onclick="removeEditThumbnail(event)">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
</div>
<!-- Slides manager (audio tracks) -->
<div id="edit-slides-section" style="display:none;">
<label class="form-label"><i class="bi bi-images"></i> Cover Slides
<span style="font-weight:400;font-size:12px;color:var(--text-secondary);margin-left:6px;">Drag to reorder · click × to remove</span>
</label>
<!-- Hidden inputs carrying order + new files -->
<input type="hidden" name="slides_order" id="edit-slides-order" value="[]">
<input type="file" name="slides_add[]" id="edit-slides-add-input" accept="image/*" multiple style="display:none;">
<div id="edit-slides-strip" style="display:flex;flex-wrap:wrap;gap:8px;min-height:72px;padding:10px;background:rgba(255,255,255,.03);border:2px dashed rgba(255,255,255,.1);border-radius:10px;margin-bottom:8px;">
<!-- Slide items injected by JS -->
<div id="edit-slides-empty" style="display:flex;align-items:center;justify-content:center;width:100%;color:var(--text-secondary);font-size:13px;">No slides yet</div>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<button type="button" class="action-btn" onclick="document.getElementById('edit-slides-add-input').click()">
<i class="bi bi-plus-lg"></i> <span>Add Images</span>
</button>
<span id="edit-slides-hint" style="font-size:12px;color:var(--text-secondary);"></span>
</div>
</div>
<div class="step-navigation">
<button type="button" class="btn-prev" onclick="editPrevStep(1)">
<i class="bi bi-arrow-left"></i> Back
<button type="button" class="action-btn btn-prev" onclick="editPrevStep(1)">
<i class="bi bi-arrow-left"></i> <span>Back</span>
</button>
<button type="button" class="btn-next" onclick="editNextStep(3)">
Next Step <i class="bi bi-arrow-right"></i>
<button type="button" class="action-btn action-btn-primary btn-next" onclick="editNextStep(3)">
<span>Next Step</span> <i class="bi bi-arrow-right"></i>
</button>
</div>
</div>
@@ -203,15 +227,52 @@
</div>
</div>
<!-- Download Access -->
<div class="form-group" style="margin: 16px 0 0;">
<label class="form-label">
<i class="bi bi-download"></i> Who can download?
</label>
<div class="options-grid" id="edit-download-options-modal">
<label class="option-item" data-download="disabled">
<input type="radio" name="download_access" value="disabled">
<div class="option-content">
<i class="bi bi-slash-circle"></i>
<span>Disabled</span>
</div>
</label>
<label class="option-item" data-download="everyone">
<input type="radio" name="download_access" value="everyone">
<div class="option-content">
<i class="bi bi-globe"></i>
<span>Everyone</span>
</div>
</label>
<label class="option-item" data-download="registered">
<input type="radio" name="download_access" value="registered">
<div class="option-content">
<i class="bi bi-person-check"></i>
<span>Members</span>
</div>
</label>
<label class="option-item" data-download="subscribers">
<input type="radio" name="download_access" value="subscribers">
<div class="option-content">
<i class="bi bi-star"></i>
<span>Subscribers</span>
</div>
</label>
</div>
</div>
<!-- Status Message -->
<div id="edit-status-message" class="status-message-modal"></div>
<div class="step-navigation">
<button type="button" class="btn-prev" onclick="editPrevStep(2)">
<i class="bi bi-arrow-left"></i> Back
<button type="button" class="action-btn btn-prev" onclick="editPrevStep(2)">
<i class="bi bi-arrow-left"></i> <span>Back</span>
</button>
<button type="submit" class="btn-save-changes" id="edit-submit-btn">
<i class="bi bi-check-lg"></i> Save Changes
<button type="submit" class="action-btn action-btn-primary btn-save-changes" id="edit-submit-btn">
<i class="bi bi-check-lg"></i> <span>Save Changes</span>
</button>
</div>
</div>
@@ -604,13 +665,7 @@
}
.dropzone-modal input[type="file"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
display: none;
}
.dropzone-icon {
@@ -795,51 +850,14 @@
border-top: 1px solid #2a2a2a;
}
.btn-prev,
.btn-next,
.btn-save-changes {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border-radius: 12px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-prev {
background: transparent;
border: 1px solid #404040;
color: #aaa;
}
.btn-prev:hover {
border-color: #666;
color: #fff;
}
.btn-next,
.btn-save-changes {
background: linear-gradient(135deg, #e61e1e 0%, #ff4757 100%);
color: white;
box-shadow: 0 4px 15px rgba(230, 30, 30, 0.3);
}
.btn-next:hover,
.btn-save-changes:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(230, 30, 30, 0.4);
}
.btn-next:disabled,
.btn-save-changes:disabled {
background: #444;
background: #444 !important;
border-color: #444 !important;
cursor: not-allowed;
transform: none;
box-shadow: none;
opacity: 1;
}
/* Status Message */
@@ -869,6 +887,51 @@
border: 1px solid rgba(239, 68, 68, 0.3);
}
/* Slide items in edit modal */
.es-item {
position: relative;
width: 72px;
height: 72px;
border-radius: 8px;
overflow: hidden;
border: 2px solid rgba(255,255,255,.12);
cursor: grab;
flex-shrink: 0;
transition: border-color .2s, transform .15s;
}
.es-item:hover { border-color: #e61e1e; }
.es-item img {
width: 100%; height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
.es-drag-handle {
position: absolute;
top: 2px; left: 2px;
width: 20px; height: 20px;
background: rgba(0,0,0,.55);
border-radius: 4px;
display: flex; align-items: center; justify-content: center;
font-size: 11px; color: #ccc;
cursor: grab;
}
.es-remove {
position: absolute;
top: 2px; right: 2px;
width: 20px; height: 20px;
background: rgba(0,0,0,.55);
border: none;
border-radius: 4px;
display: flex; align-items: center; justify-content: center;
font-size: 10px; color: #ccc;
cursor: pointer;
padding: 0;
transition: background .15s, color .15s;
}
.es-remove:hover { background: #e61e1e; color: #fff; }
.es-item.es-dragging { opacity: .35; outline: 2px dashed #e61e1e; }
/* Responsive */
@media (max-width: 576px) {
.edit-modal-body {
@@ -908,6 +971,12 @@
// Open modal and load video data
function openEditVideoModal(videoId) {
// On mobile redirect to the full edit page
if (window.innerWidth < 992) {
window.location.href = `/videos/${videoId}/edit`;
return;
}
window.currentVideoId = videoId;
// Fetch video data
@@ -960,11 +1029,31 @@
}
});
// Set download_access radio cards
const dlValue = video.download_access || 'disabled';
document.querySelectorAll('#edit-download-options-modal .option-item').forEach(opt => {
opt.classList.remove('active');
const radio = opt.querySelector('input[type="radio"]');
if (radio.value === dlValue) {
radio.checked = true;
opt.classList.add('active');
} else {
radio.checked = false;
}
});
// Reset file inputs
document.getElementById('edit-thumbnail-default').style.display = 'block';
document.getElementById('edit-thumbnail-info').classList.remove('active');
document.getElementById('edit-status-message').className = 'status-message-modal';
// Populate slides or thumbnail section
populateEditSlides(video.slides || [], video.is_audio || false);
// Update step 2 label dynamically
const step2Label = document.querySelector('.edit-step[data-step="2"] .step-label');
if (step2Label) step2Label.textContent = video.is_audio ? 'Slides' : 'Thumbnail';
// Reset step to 1
window.currentEditStep = 1;
updateEditStepDisplay();
@@ -980,12 +1069,12 @@
document.getElementById('editVideoModal').classList.add('show');
}, 10);
} else {
alert('Failed to load video data');
showToast('Failed to load video data', 'error');
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to load video data');
showToast('Failed to load video data', 'error');
});
}
@@ -1011,7 +1100,7 @@
if (step === 2) {
const title = document.getElementById('edit-video-title').value.trim();
if (!title) {
alert('Please enter a title for your video');
showToast('Please enter a title for your video', 'error');
return;
}
}
@@ -1066,6 +1155,15 @@
});
if (editThumbnailDropzone) {
editThumbnailDropzone.addEventListener('click', function(e) {
if (e.target.closest('.btn-remove-file')) return;
if (typeof window.openCropperModal_thumb_edit === 'function') {
window.openCropperModal_thumb_edit();
} else {
editThumbnailInput.click();
}
});
editThumbnailDropzone.addEventListener('dragover', (e) => {
e.preventDefault();
editThumbnailDropzone.classList.add('dragover');
@@ -1079,8 +1177,14 @@
e.preventDefault();
editThumbnailDropzone.classList.remove('dragover');
if (e.dataTransfer.files.length) {
editThumbnailInput.files = e.dataTransfer.files;
handleEditThumbnailSelect(editThumbnailInput);
const droppedFile = e.dataTransfer.files[0];
if (typeof window.tcPreload_thumb_edit === 'function') {
window.tcPreload_thumb_edit(droppedFile);
window.openCropperModal_thumb_edit();
} else {
editThumbnailInput.files = e.dataTransfer.files;
handleEditThumbnailSelect(editThumbnailInput);
}
}
});
}
@@ -1113,6 +1217,109 @@
document.getElementById('edit-thumbnail-info').classList.remove('active');
}
// ── Slides manager (audio tracks) ────────────────────────────
let editSlidesData = []; // [{id, url}] for existing; {file, url} for new additions
let editDragSrc = null;
function _esSyncOrder() {
const strip = document.getElementById('edit-slides-strip');
const keptIds = editSlidesData.filter(s => s.id).map(s => s.id);
document.getElementById('edit-slides-order').value = JSON.stringify(keptIds);
const count = editSlidesData.length;
document.getElementById('edit-slides-hint').textContent =
count === 1 ? '1 image — static cover' : count + ' images — will crossfade';
}
function editSlidesRefresh() {
const strip = document.getElementById('edit-slides-strip');
const empty = document.getElementById('edit-slides-empty');
strip.querySelectorAll('.es-item').forEach(el => el.remove());
empty.style.display = editSlidesData.length === 0 ? 'flex' : 'none';
editSlidesData.forEach((item, idx) => {
const div = document.createElement('div');
div.className = 'es-item';
div.draggable = true;
div._esData = item; // reference stored on element — avoids stale-index issues during drag
div.innerHTML = `
<div class="es-drag-handle" title="Drag to reorder"><i class="bi bi-grip-vertical"></i></div>
<img src="${item.url}" alt="">
<button type="button" class="es-remove" title="Remove"><i class="bi bi-x-lg"></i></button>
`;
div.querySelector('.es-remove').addEventListener('click', () => {
editSlidesData.splice(editSlidesData.indexOf(div._esData), 1);
editSlidesRefresh();
});
div.addEventListener('dragstart', e => {
editDragSrc = div;
e.dataTransfer.effectAllowed = 'move';
setTimeout(() => div.classList.add('es-dragging'), 0);
});
// On dragend: sync editSlidesData from current DOM order, then update inputs
div.addEventListener('dragend', () => {
div.classList.remove('es-dragging');
editDragSrc = null;
const newData = [];
strip.querySelectorAll('.es-item').forEach(el => newData.push(el._esData));
editSlidesData = newData;
_esSyncOrder();
});
// On dragover: just move the element in the DOM — never re-render
div.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (!editDragSrc || editDragSrc === div) return;
const items = [...strip.querySelectorAll('.es-item')];
const srcPos = items.indexOf(editDragSrc);
const tgtPos = items.indexOf(div);
if (srcPos < tgtPos) div.after(editDragSrc);
else div.before(editDragSrc);
});
strip.appendChild(div);
});
_esSyncOrder();
}
// "Add Images" picker
document.getElementById('edit-slides-add-input').addEventListener('change', function() {
Array.from(this.files).forEach(file => {
const reader = new FileReader();
reader.onload = e => {
editSlidesData.push({ file, url: e.target.result });
editSlidesRefresh();
};
reader.readAsDataURL(file);
});
// Reset input so same files can be re-added after removal
this.value = '';
});
function populateEditSlides(slides, isAudio) {
document.getElementById('edit-thumb-section').style.display = isAudio ? 'none' : '';
document.getElementById('edit-slides-section').style.display = isAudio ? '' : 'none';
if (! isAudio) return;
editSlidesData = slides.map(s => ({ id: s.id, url: s.url }));
editSlidesRefresh();
}
// Inject new file inputs into the form on submit (FormData can't handle dynamic files otherwise)
function buildEditSlidesFormData(formData) {
// Remove existing slides_add from formData (the file input may have stale data)
// Instead append each new file individually
const newFiles = editSlidesData.filter(s => s.file);
newFiles.forEach(s => formData.append('slides_add[]', s.file, s.file.name));
return formData;
}
// Video type options
const videoTypeOptions = document.querySelectorAll('.video-type-option');
videoTypeOptions.forEach(option => {
@@ -1133,11 +1340,21 @@
});
});
// Download access options
document.querySelectorAll('#edit-download-options-modal .option-item').forEach(item => {
item.querySelector('input[type="radio"]').addEventListener('change', function() {
document.querySelectorAll('#edit-download-options-modal .option-item').forEach(o => o.classList.remove('active'));
item.classList.add('active');
});
});
// Form submission
document.getElementById('edit-video-form-modal').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
let formData = new FormData(this);
// Append any newly added slide files (not captured by FormData from hidden input)
formData = buildEditSlidesFormData(formData);
const submitBtn = document.getElementById('edit-submit-btn');
// Disable button and show loading
@@ -1187,3 +1404,14 @@
});
});
</script>
<x-image-cropper
id="thumb_edit"
:width="448"
:height="252"
shape="square"
target-input="edit-thumbnail-input"
preview-img="edit-thumbnail-preview-img"
output-width="1280"
title="Crop Thumbnail"
/>
+75 -229
View File
@@ -1,256 +1,102 @@
<!-- Header -->
<header class="yt-header">
<style>
.yt-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 56px;
background: #0f0f0f;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
z-index: 1000;
border-bottom: 1px solid #303030;
}
.yt-header-left {
display: flex;
align-items: center;
gap: 16px;
}
.yt-menu-btn {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
background: transparent;
border: none;
color: #f1f1f1;
}
.yt-menu-btn:hover { background: #303030; }
.yt-logo {
display: flex;
align-items: center;
text-decoration: none;
gap: 4px;
}
.yt-logo-text {
font-size: 1.4rem;
font-weight: 700;
color: #f1f1f1;
letter-spacing: -1px;
}
.yt-header-center {
flex: 1;
max-width: 640px;
margin: 0 40px;
display: flex;
align-items: center;
justify-content: center;
}
.yt-search {
flex: 1;
display: flex;
height: 40px;
}
.yt-search-input {
flex: 1;
background: #121212;
border: 1px solid #303030;
border-right: none;
border-radius: 20px 0 0 20px;
padding: 0 16px;
color: #f1f1f1;
font-size: 16px;
}
.yt-search-input:focus {
outline: none;
border-color: #1c62b9;
}
.yt-search-btn {
width: 64px;
background: #222;
border: 1px solid #303030;
border-radius: 0 20px 20px 0;
color: #f1f1f1;
cursor: pointer;
}
.yt-search-btn:hover { background: #303030; }
.yt-header-right {
display: flex;
align-items: center;
gap: 8px;
}
.yt-mobile-search-toggle {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
background: transparent;
border: none;
color: #f1f1f1;
font-size: 1.2rem;
}
.yt-mobile-search-toggle:hover {
background: #303030;
}
.yt-upload-btn {
background: #e61e1e;
color: white;
border: none;
padding: 8px 16px;
border-radius: 20px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
text-decoration: none;
}
.yt-upload-btn:hover { background: #cc1a1a; }
.yt-icon-btn {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
background: transparent;
border: none;
color: #f1f1f1;
font-size: 1.2rem;
}
.yt-icon-btn:hover { background: #303030; }
.yt-user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #555;
cursor: pointer;
}
@media (max-width: 576px) {
.yt-upload-btn {
width: 40px;
height: 40px;
padding: 0;
border-radius: 50%;
justify-content: center;
}
.yt-upload-btn span {
display: none;
}
@media (min-width: 400px) {
.yt-upload-btn {
width: auto;
border-radius: 20px;
padding: 8px 16px;
}
.yt-upload-btn span {
display: inline;
}
}
}
@media (max-width: 768px) {
.yt-header-center { display: none; }
}
</style>
<div class="yt-header-left">
<button class="yt-menu-btn" onclick="toggleSidebar()">
<i class="bi bi-list fs-5"></i>
</button>
<a href="{{ route('home') }}" class="yt-logo">
<!-- Mobile logo (visible only on mobile) -->
<img src="{{ asset('storage/images/logo.png') }}" alt="{{ config('app.name') }}" class="d-md-none" style="height: 30px;">
<!-- Desktop logo (visible only on desktop) -->
<img src="{{ asset('storage/images/fullLogo.png') }}" alt="{{ config('app.name') }}" class="d-none d-md-block" style="height: 30px;">
<img src="{{ asset('storage/images/logo.png') }}" alt="{{ config('app.name') }}" class="d-md-none" style="height:30px;">
<img src="{{ asset('storage/images/fullLogo.png') }}" alt="{{ config('app.name') }}" class="d-none d-md-block" style="height:30px;">
</a>
</div>
<div class="yt-header-center d-none d-md-flex">
<form action="{{ route('videos.search') }}" method="GET" class="yt-search">
<input type="text" name="q" class="yt-search-input" placeholder="Search" value="{{ request('q') }}">
<button type="submit" class="yt-search-btn">
<i class="bi bi-search"></i>
</button>
</form>
<div class="yt-header-center d-none d-md-flex">
<div class="yt-search">
<form action="{{ route('videos.search') }}" method="GET" class="yt-search-form">
<input type="text" name="q" class="yt-search-input" placeholder="Search" value="{{ request('q') }}">
<button type="submit" class="yt-search-btn"><i class="bi bi-search"></i></button>
</form>
</div>
</div>
<div class="yt-header-right">
<!-- Mobile Search Toggle Button -->
<button type="button" class="yt-mobile-search-toggle d-md-none" onclick="toggleMobileSearch()">
<!-- Mobile search -->
<button type="button" class="yt-icon-btn d-md-none" onclick="toggleMobileSearch()">
<i class="bi bi-search"></i>
</button>
@auth
<!-- Upload Button - Opens Modal -->
<button type="button" class="yt-upload-btn d-none d-md-flex" data-bs-toggle="modal" data-bs-target="#uploadModal">
<i class="bi bi-plus-lg"></i>
<span>Upload</span>
<!-- Create / Upload -->
<button type="button" class="yt-upload-btn d-none d-md-flex"
data-bs-toggle="modal" data-bs-target="#uploadModal">
<i class="bi bi-camera-video-fill"></i>
<span>Create</span>
</button>
<!-- User Dropdown -->
<div class="dropdown">
<button class="yt-icon-btn" data-bs-toggle="dropdown" aria-expanded="false">
@if(Auth::user()->avatar)
<img src="{{ asset('storage/avatars/' . Auth::user()->avatar) }}" class="yt-user-avatar" alt="User">
@else
<img src="https://i.pravatar.cc/150?u={{ Auth::user()->id }}" class="yt-user-avatar" alt="User">
@endif
<!-- Notifications -->
<div class="yt-notif-wrap" id="notifWrap">
<button type="button" class="yt-icon-btn" id="notifBtn"
onclick="toggleNotifPanel()" title="Notifications" aria-expanded="false">
<i class="bi bi-bell-fill"></i>
<span class="yt-notif-badge" id="notifBadge" style="display:none;"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
@if(Auth::user()->isSuperAdmin())
<li><a class="dropdown-item" href="{{ route('admin.dashboard') }}"><i class="bi bi-speedometer2"></i> Admin Dashboard</a></li>
<li><hr class="dropdown-divider"></li>
@endif
<li><a class="dropdown-item" href="{{ route('profile') }}"><i class="bi bi-person"></i> Profile</a></li>
<li><a class="dropdown-item" href="{{ route('channel', Auth::user()->id) }}"><i class="bi bi-play-btn"></i> My Channel</a></li>
<li><a class="dropdown-item" href="{{ route('settings') }}"><i class="bi bi-gear"></i> Settings</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="POST" action="{{ route('logout') }}" class="m-0">
@csrf
<button type="submit" class="dropdown-item text-danger"><i class="bi bi-box-arrow-right"></i> Logout</button>
</form>
</li>
</ul>
<div class="yt-notif-panel" id="notifPanel" style="display:none;" role="menu">
<div class="yt-notif-header">
<span class="yt-notif-title">Notifications</span>
<button type="button" class="yt-notif-mark-all" id="notifMarkAll"
onclick="markAllRead()" style="display:none;">Mark all read</button>
</div>
<div class="yt-notif-list" id="notifList">
<div class="yt-notif-empty">Loading…</div>
</div>
</div>
</div>
<!-- User dropdown -->
<div class="yt-user-dropdown" id="headerUserDropdown">
<button class="yt-icon-btn" id="headerUserBtn"
onclick="toggleHeaderDropdown()" aria-expanded="false" aria-haspopup="true">
<img src="{{ Auth::user()->avatar_url }}"
class="yt-user-avatar" alt="{{ Auth::user()->name }}">
</button>
<div class="yt-user-panel" id="headerUserPanel" role="menu">
<div class="yt-panel-info">
<img src="{{ Auth::user()->avatar_url }}" alt="{{ Auth::user()->name }}">
<div style="min-width:0;">
<div class="yt-panel-name">{{ Auth::user()->name }}</div>
<div class="yt-panel-email">{{ Auth::user()->email }}</div>
</div>
</div>
<div class="yt-panel-links">
@if(Auth::user()->isSuperAdmin())
<a href="{{ route('admin.dashboard') }}" class="yt-drop-item">
<i class="bi bi-speedometer2"></i> Admin Dashboard
</a>
<div class="yt-panel-divider"></div>
@endif
<a href="{{ route('channel', Auth::user()->channel) }}" class="yt-drop-item">
<i class="bi bi-person-video3"></i> My Channel
</a>
<a href="{{ route('channel', Auth::user()->channel) }}#settings" class="yt-drop-item">
<i class="bi bi-gear"></i> Settings
</a>
<div class="yt-panel-divider"></div>
<form method="POST" action="{{ route('logout') }}" style="margin:0;">
@csrf
<button type="submit" class="yt-drop-item danger">
<i class="bi bi-box-arrow-right"></i> Sign Out
</button>
</form>
</div>
</div>
</div>
@else
<!-- Login/Register for visitors -->
<a href="{{ route('login') }}" class="yt-upload-btn">
<i class="bi bi-box-arrow-in-right"></i>
<span>Login</span>
<span>Sign In</span>
</a>
@endauth
</div>
@@ -4,19 +4,18 @@
<div class="modal-content" style="background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 16px;">
<div class="modal-header" style="border-bottom: 1px solid var(--border-color); padding: 20px 24px;">
<h5 class="modal-title" id="shareModalLabel" style="font-weight: 600; color: var(--text-primary);">
<i class="bi bi-share me-2"></i>Share Video
<i class="bi bi-share me-2"></i>Share
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" style="padding: 24px;">
<p style="color: var(--text-secondary); margin-bottom: 16px;">Share this video with your friends:</p>
<p style="color: var(--text-secondary); margin-bottom: 16px;">Share this link with your friends:</p>
<div class="share-link-container" style="display: flex; gap: 8px; align-items: center;">
<input type="text" id="shareLinkInput" class="form-control" readonly
style="background: var(--bg-primary); border: 1px solid var(--border-color); color: var(--text-primary); padding: 12px 16px; border-radius: 8px;">
<button type="button" id="copyLinkBtn" class="btn-copy"
style="background: var(--brand-red); color: white; border: none; padding: 12px 20px; border-radius: 8px; font-weight: 500; cursor: pointer; white-space: nowrap; transition: background 0.2s;">
<i class="bi bi-clipboard me-1"></i> Copy
<button type="button" id="copyLinkBtn" class="btn-copy action-btn action-btn-primary">
<i class="bi bi-clipboard"></i> <span>Copy</span>
</button>
</div>
@@ -58,67 +57,113 @@
transform: scale(1.1);
opacity: 0.9;
}
.btn-copy:hover {
background: #cc1a1a !important;
}
.btn-copy.copied {
background: #4caf50 !important;
border-color: #4caf50 !important;
}
</style>
<script>
function openShareModal(videoUrl, videoTitle) {
document.getElementById('shareLinkInput').value = videoUrl;
// Update social share links
var encodedUrl = encodeURIComponent(videoUrl);
var encodedTitle = encodeURIComponent(videoTitle);
document.getElementById('shareFacebook').href = 'https://www.facebook.com/sharer/sharer.php?u=' + encodedUrl;
document.getElementById('shareTwitter').href = 'https://twitter.com/intent/tweet?url=' + encodedUrl + '&text=' + encodedTitle;
document.getElementById('shareWhatsApp').href = 'https://wa.me/?text=' + encodedTitle + ' ' + encodedUrl;
document.getElementById('shareTelegram').href = 'https://t.me/share/url?url=' + encodedUrl + '&text=' + encodedTitle;
// Reset copy button state
var copyBtn = document.getElementById('copyLinkBtn');
copyBtn.innerHTML = '<i class="bi bi-clipboard me-1"></i> Copy';
copyBtn.classList.remove('copied');
document.getElementById('copySuccess').style.display = 'none';
// Show modal
var modal = new bootstrap.Modal(document.getElementById('shareModal'));
function _getLatestCsrf() {
var match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
if (match) return decodeURIComponent(match[1]);
return (typeof csrf !== 'undefined') ? csrf : '';
}
async function openShareModal(videoUrl, videoTitle, recordUrl) {
var csrfToken = _getLatestCsrf();
var shareUrl = videoUrl;
// Obtain a unique tracked share link from the server
if (recordUrl) {
try {
var res = await fetch(recordUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken,
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
});
if (res.ok) {
var data = await res.json();
if (data.url) shareUrl = data.url;
}
} catch (e) { /* fallback to plain URL */ }
}
// Mobile: use native share sheet with the unique link
if (window.innerWidth <= 768 && navigator.share) {
navigator.share({ title: videoTitle, url: shareUrl }).catch(function() {});
return;
}
// Desktop: show modal
_populateShareModal(shareUrl, videoTitle);
var modal = new bootstrap.Modal(document.getElementById('shareModal'), { backdrop: true, keyboard: true });
modal.show();
}
document.addEventListener('DOMContentLoaded', function() {
function _populateShareModal(shareUrl, videoTitle) {
document.getElementById('shareLinkInput').value = shareUrl;
var encodedUrl = encodeURIComponent(shareUrl);
var encodedTitle = encodeURIComponent(videoTitle);
var waText = encodeURIComponent(videoTitle + '\n' + shareUrl);
document.getElementById('shareFacebook').href = 'https://www.facebook.com/sharer/sharer.php?u=' + encodedUrl;
document.getElementById('shareTwitter').href = 'https://twitter.com/intent/tweet?url=' + encodedUrl + '&text=' + encodedTitle;
document.getElementById('shareWhatsApp').href = 'https://wa.me/?text=' + waText;
document.getElementById('shareTelegram').href = 'https://t.me/share/url?url=' + encodedUrl + '&text=' + encodedTitle;
var copyBtn = document.getElementById('copyLinkBtn');
var shareInput = document.getElementById('shareLinkInput');
var copySuccess = document.getElementById('copySuccess');
if (copyBtn && shareInput) {
copyBtn.addEventListener('click', function() {
shareInput.select();
shareInput.setSelectionRange(0, 99999);
navigator.clipboard.writeText(shareInput.value).then(function() {
copyBtn.innerHTML = '<i class="bi bi-check-lg me-1"></i> Copied!';
copyBtn.classList.add('copied');
copySuccess.style.display = 'block';
setTimeout(function() {
copyBtn.innerHTML = '<i class="bi bi-clipboard me-1"></i> Copy';
copyBtn.classList.remove('copied');
copySuccess.style.display = 'none';
}, 2000);
}).catch(function(err) {
console.error('Failed to copy: ', err);
// Fallback for older browsers
document.execCommand('copy');
copyBtn.innerHTML = '<i class="bi bi-check-lg me-1"></i> Copied!';
copySuccess.style.display = 'block';
});
});
copyBtn.innerHTML = '<i class="bi bi-clipboard"></i> <span>Copy</span>';
copyBtn.classList.remove('copied');
document.getElementById('copySuccess').style.display = 'none';
}
function _copyToClipboard(text) {
// Prefer modern clipboard API (requires HTTPS)
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
}
// Textarea fallback — works even inside Bootstrap modals
return new Promise(function(resolve, reject) {
var ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0;';
document.body.appendChild(ta);
ta.focus();
ta.select();
var ok = false;
try { ok = document.execCommand('copy'); } catch(e) {}
document.body.removeChild(ta);
ok ? resolve() : reject();
});
}
document.addEventListener('DOMContentLoaded', function() {
var copyBtn = document.getElementById('copyLinkBtn');
var shareInput = document.getElementById('shareLinkInput');
var copySuccess = document.getElementById('copySuccess');
if (!copyBtn || !shareInput) return;
copyBtn.addEventListener('click', function() {
_copyToClipboard(shareInput.value).then(function() {
copyBtn.innerHTML = '<i class="bi bi-check-lg"></i> <span>Copied!</span>';
copyBtn.classList.add('copied');
copySuccess.style.display = 'block';
setTimeout(function() {
copyBtn.innerHTML = '<i class="bi bi-clipboard"></i> <span>Copy</span>';
copyBtn.classList.remove('copied');
copySuccess.style.display = 'none';
}, 2500);
}).catch(function() {
showToast('Could not copy — please copy the link manually.', 'error');
});
});
});
</script>
@@ -1,12 +1,14 @@
<!-- Sidebar -->
<nav class="yt-sidebar" id="sidebar">
<!-- Primary section -->
<div class="yt-sidebar-section">
<a href="{{ route('home') }}" class="yt-sidebar-link {{ request()->is('/') || request()->is('videos') ? 'active' : '' }}">
<i class="bi bi-house-door-fill"></i>
<span>Home</span>
</a>
<a href="{{ route('videos.shorts') }}" class="yt-sidebar-link {{ request()->is('shorts') ? 'active' : '' }}">
<i class="bi bi-play-btn"></i>
<i class="bi bi-play-btn-fill"></i>
<span>Shorts</span>
</a>
<a href="{{ route('videos.trending') }}" class="yt-sidebar-link {{ request()->is('trending') ? 'active' : '' }}">
@@ -15,36 +17,94 @@
</a>
@auth
<a href="#" class="yt-sidebar-link">
<i class="bi bi-collection-play"></i>
<i class="bi bi-collection-play-fill"></i>
<span>Subscriptions</span>
</a>
@endauth
</div>
<!-- You section (auth only) -->
@auth
@php $channelBase = route('channel', Auth::user()->channel); @endphp
<div class="yt-sidebar-section">
<a href="{{ route('channel', Auth::user()->id) }}" class="yt-sidebar-link">
<i class="bi bi-person-video"></i>
<span>Your Channel</span>
<div class="yt-sidebar-section-header">You</div>
<a href="{{ $channelBase }}#wall"
class="yt-sidebar-link" id="sb-tab-wall">
<i class="bi bi-layout-text-sidebar-reverse"></i>
<span>My Wall</span>
</a>
<a href="{{ route('playlists.index') }}" class="yt-sidebar-link {{ request()->is('playlists*') ? 'active' : '' }}">
<i class="bi bi-collection-play"></i>
<span>Playlists</span>
<a href="{{ $channelBase }}#videos"
class="yt-sidebar-link" id="sb-tab-videos">
<i class="bi bi-play-circle-fill"></i>
<span>My Videos</span>
</a>
<a href="{{ route('history') }}" class="yt-sidebar-link">
<a href="{{ $channelBase }}#shorts"
class="yt-sidebar-link" id="sb-tab-shorts">
<i class="bi bi-phone-fill"></i>
<span>My Shorts</span>
</a>
<a href="{{ $channelBase }}#playlists"
class="yt-sidebar-link" id="sb-tab-playlists">
<i class="bi bi-collection-play-fill"></i>
<span>My Playlists</span>
</a>
<a href="{{ route('history') }}" class="yt-sidebar-link {{ request()->is('history') ? 'active' : '' }}">
<i class="bi bi-clock-history"></i>
<span>History</span>
</a>
<a href="{{ route('liked') }}" class="yt-sidebar-link">
<i class="bi bi-hand-thumbs-up"></i>
<a href="{{ route('liked') }}" class="yt-sidebar-link {{ request()->is('liked') ? 'active' : '' }}">
<i class="bi bi-hand-thumbs-up-fill"></i>
<span>Liked Videos</span>
</a>
@if(Auth::user()->isSuperAdmin())
<a href="{{ route('admin.dashboard') }}" class="yt-sidebar-link {{ request()->is('admin*') ? 'active' : '' }}">
<i class="bi bi-speedometer2"></i>
<span>Admin Panel</span>
</a>
@endif
</div>
@endauth
<!-- Explore section -->
<div class="yt-sidebar-section">
<div class="yt-sidebar-section-header">Explore</div>
<a href="{{ route('videos.index', ['filter' => 'music']) }}" class="yt-sidebar-link {{ request()->is('videos') && request('filter') === 'music' ? 'active' : '' }}">
<i class="bi bi-music-note-beamed"></i>
<span>Music</span>
</a>
<a href="{{ route('videos.index', ['filter' => 'match']) }}" class="yt-sidebar-link {{ request()->is('videos') && request('filter') === 'match' ? 'active' : '' }}">
<i class="bi bi-trophy-fill"></i>
<span>Sports</span>
</a>
</div>
<!-- Admin section (super_admin only) -->
@auth
@if(Auth::user()->isSuperAdmin())
<div class="yt-sidebar-section">
<div class="yt-sidebar-section-header">Admin</div>
<a href="{{ route('admin.dashboard') }}" class="yt-sidebar-link {{ request()->is('admin*') ? 'active' : '' }}">
<i class="bi bi-speedometer2"></i>
<span>Dashboard</span>
</a>
</div>
@endif
@endauth
</nav>
@auth
<script>
(function () {
var channelPath = @json(parse_url(route('channel', Auth::user()->channel), PHP_URL_PATH));
var onChannel = location.pathname === channelPath;
if (!onChannel) return;
var hash = location.hash.replace('#', '') || 'wall';
var el = document.getElementById('sb-tab-' + hash);
if (el) el.classList.add('active');
window.addEventListener('hashchange', function () {
document.querySelectorAll('#sb-tab-wall,#sb-tab-videos,#sb-tab-shorts,#sb-tab-playlists')
.forEach(function (a) { a.classList.remove('active'); });
var h = location.hash.replace('#', '') || 'wall';
var t = document.getElementById('sb-tab-' + h);
if (t) t.classList.add('active');
});
}());
</script>
@endauth
@@ -22,19 +22,20 @@
<form id="upload-form-modal" enctype="multipart/form-data">
@csrf
<!-- Video File -->
<!-- Video / Audio File -->
<div class="form-group">
<label class="form-label">
<i class="bi bi-camera-video"></i> Video File *
<label class="form-label" id="file-label-modal">
<i class="bi bi-camera-video" id="file-label-icon"></i>
<span id="file-label-text">Video or Music File *</span>
</label>
<div id="video-dropzone-modal" class="dropzone-modal">
<input type="file" name="video" id="video-modal" accept="video/*" required>
<input type="file" name="video" id="video-modal" accept="video/*,audio/mpeg,audio/mp4,audio/aac,audio/flac,audio/wav,.mp3,.m4a,.aac,.flac,.wav" required>
<div id="dropzone-default-modal">
<div class="dropzone-icon">
<i class="bi bi-cloud-arrow-up"></i>
</div>
<p class="dropzone-title">Click to select or drag video here</p>
<p class="dropzone-hint">MP4, MOV, AVI, WebM and more</p>
<p class="dropzone-title">Click to select or drag file here</p>
<p class="dropzone-hint">Video: MP4, MOV, AVI, WebM — Audio: MP3, M4A, AAC, FLAC</p>
</div>
<div id="file-info-modal" class="file-info-modal">
<div class="file-preview">
@@ -71,10 +72,10 @@
placeholder="Tell viewers about your video (Markdown supported)"></textarea>
</div>
<!-- Thumbnail -->
<div class="form-group">
<label class="form-label">
<i class="bi bi-image"></i> Thumbnail
<!-- Thumbnail (video) / Slides (audio) -->
<div class="form-group" id="thumbnail-section-modal">
<label class="form-label" id="thumbnail-label-modal">
<i class="bi bi-image"></i> <span id="thumbnail-label-text">Thumbnail</span>
</label>
<div id="thumbnail-dropzone-modal" class="dropzone-modal thumbnail-dropzone">
<input type="file" name="thumbnail" id="thumbnail-modal" accept="image/*">
@@ -82,7 +83,7 @@
<div class="dropzone-icon thumbnail-icon">
<i class="bi bi-card-image"></i>
</div>
<p class="dropzone-title">Click to select thumbnail (optional)</p>
<p class="dropzone-title" id="thumbnail-dropzone-hint">Click to select thumbnail (optional)</p>
</div>
<div id="thumbnail-info-modal" class="file-info-modal thumbnail-info">
<div class="file-preview thumbnail-preview">
@@ -99,6 +100,30 @@
</div>
</div>
<!-- Slides dropzone (audio only) -->
<div class="form-group" id="slides-section-modal" style="display:none;">
<label class="form-label">
<i class="bi bi-images"></i> Slides <span style="color:#f00;">*</span>
<span style="font-weight:400;font-size:12px;color:var(--text-secondary);margin-left:6px;">Select 1–10 images — multiple images will crossfade</span>
</label>
<div id="slides-dropzone-modal" class="dropzone-modal thumbnail-dropzone" style="min-height:80px;">
<input type="file" name="slides[]" id="slides-modal" accept="image/*" multiple style="display:none;">
<div id="slides-default-modal">
<div class="dropzone-icon thumbnail-icon"><i class="bi bi-images"></i></div>
<p class="dropzone-title">Click or drag to add cover images</p>
</div>
<div id="slides-preview-modal" style="display:none;padding:10px 12px;">
<div id="slides-strip-modal" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px;"></div>
<div style="display:flex;align-items:center;justify-content:space-between;">
<span id="slides-count-modal" style="font-size:12px;color:var(--text-secondary);"></span>
<button type="button" onclick="removeSlidesModal(event)" style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:12px;padding:0;">
<i class="bi bi-x-lg"></i> Clear
</button>
</div>
</div>
</div>
</div>
<!-- Video Type -->
<div class="form-group">
<label class="form-label">
@@ -162,6 +187,43 @@
<!-- Hidden Shorts -->
<input type="hidden" name="is_shorts" id="is_shorts_modal" value="0">
<!-- Download Access -->
<div class="form-group" style="margin-bottom: 16px;">
<label class="form-label">
<i class="bi bi-download"></i> Who can download?
</label>
<div class="options-grid" id="download-options-modal">
<label class="option-item active" data-download="disabled">
<input type="radio" name="download_access" value="disabled" checked>
<div class="option-content">
<i class="bi bi-slash-circle"></i>
<span>Disabled</span>
</div>
</label>
<label class="option-item" data-download="everyone">
<input type="radio" name="download_access" value="everyone">
<div class="option-content">
<i class="bi bi-globe"></i>
<span>Everyone</span>
</div>
</label>
<label class="option-item" data-download="registered">
<input type="radio" name="download_access" value="registered">
<div class="option-content">
<i class="bi bi-person-check"></i>
<span>Members</span>
</div>
</label>
<label class="option-item" data-download="subscribers">
<input type="radio" name="download_access" value="subscribers">
<div class="option-content">
<i class="bi bi-star"></i>
<span>Subscribers</span>
</div>
</label>
</div>
</div>
<!-- Progress Bar -->
<div id="progress-container-modal" class="progress-container-modal">
<div class="progress-bar-wrapper">
@@ -174,8 +236,8 @@
<div id="status-message-modal" class="status-message-modal"></div>
<!-- Submit Button -->
<button type="submit" id="submit-btn-modal" class="btn-submit">
<i class="bi bi-cloud-arrow-up-fill"></i> Upload Video
<button type="submit" id="submit-btn-modal" class="action-btn action-btn-primary btn-submit">
<i class="bi bi-cloud-arrow-up-fill"></i> <span>Upload Video</span>
</button>
</form>
</div>
@@ -324,13 +386,7 @@
}
.dropzone-modal input[type="file"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
display: none;
}
.dropzone-icon {
@@ -437,6 +493,7 @@
.btn-remove-file {
width: 28px;
height: 28px;
padding: 0;
border-radius: 50%;
background: #333;
border: none;
@@ -569,28 +626,16 @@
/* Submit Button */
.btn-submit {
width: 100%;
background: #e61e1e;
color: white;
border: none;
padding: 14px 24px;
border-radius: 8px;
justify-content: center;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-submit:hover {
background: #cc1a1a;
}
.btn-submit:disabled {
background: #555;
background: #555 !important;
border-color: #555 !important;
cursor: not-allowed;
opacity: 1;
}
/* Responsive */
@@ -673,6 +718,15 @@ function resetUploadForm() {
item.classList.add('active');
}
});
document.querySelectorAll('#download-options-modal .option-item').forEach(item => {
item.classList.remove('active');
item.querySelector('input').checked = false;
if (item.dataset.download === 'disabled') {
item.classList.add('active');
item.querySelector('input').checked = true;
}
});
}
// Format file size
@@ -693,7 +747,8 @@ videoInputModal.addEventListener('change', function() {
});
videoDropzoneModal.addEventListener('click', function(e) {
if (e.target.classList.contains('btn-remove-file')) return;
if (e.target.closest('.btn-remove-file')) return;
videoInputModal.click();
});
videoDropzoneModal.addEventListener('dragover', (e) => {
@@ -714,23 +769,56 @@ videoDropzoneModal.addEventListener('drop', (e) => {
}
});
const AUDIO_EXTENSIONS = ['mp3', 'm4a', 'aac', 'flac', 'wav'];
function isAudioFile(file) {
const ext = file.name.split('.').pop().toLowerCase();
return AUDIO_EXTENSIONS.includes(ext) || file.type.startsWith('audio/');
}
function setAudioMode(audio) {
// Label
document.getElementById('file-label-icon').className = audio ? 'bi bi-music-note-beamed' : 'bi bi-camera-video';
document.getElementById('file-label-text').textContent = audio ? 'Music File *' : 'Video or Music File *';
// Toggle thumbnail vs slides section
document.getElementById('thumbnail-section-modal').style.display = audio ? 'none' : '';
document.getElementById('slides-section-modal').style.display = audio ? '' : 'none';
document.getElementById('slides-dropzone-modal').style.borderColor = audio ? '#e61e1e' : '';
// Auto-select Music type when audio
if (audio) {
document.querySelectorAll('#type-options-modal .option-item').forEach(o => o.classList.remove('active'));
const musicOption = document.querySelector('#type-options-modal .option-item[data-type="music"]');
if (musicOption) {
musicOption.classList.add('active');
musicOption.querySelector('input').checked = true;
}
}
// Update file preview icon
const previewIcon = document.querySelector('#file-info-modal .file-preview i');
if (previewIcon) previewIcon.className = audio ? 'bi bi-music-note-beamed' : 'bi bi-film';
// Update submit label
document.getElementById('submit-btn-modal').innerHTML =
'<i class="bi bi-cloud-arrow-up-fill"></i> <span>' + (audio ? 'Upload Track' : 'Upload Video') + '</span>';
}
function handleVideoSelectModal(input) {
if (input.files && input.files[0]) {
const file = input.files[0];
const maxSize = 512 * 1024 * 1024; // 512MB
// Validate file type
const validTypes = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime', 'video/x-msvideo', 'video/x-flv', 'video/x-matroska'];
const validExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.wmv', '.flv', '.mkv'];
const validVideoTypes = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime', 'video/x-msvideo', 'video/x-flv', 'video/x-matroska'];
const validVideoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.wmv', '.flv', '.mkv'];
const ext = '.' + file.name.split('.').pop().toLowerCase();
let isValidType = validTypes.includes(file.type);
let fileExtension = '.' + file.name.split('.').pop().toLowerCase();
if (!isValidType) {
isValidType = validExtensions.includes(fileExtension);
}
const audio = isAudioFile(file);
const validVideo = validVideoTypes.includes(file.type) || validVideoExtensions.includes(ext);
if (!isValidType) {
alert('Invalid video format. Please select a valid video file (MP4, MOV, AVI, WebM, OGG, WMV, FLV, MKV).');
if (!audio && !validVideo) {
showToast('Invalid format. Accepted: MP4, MOV, AVI, WebM, MKV, MP3, M4A, AAC, FLAC, WAV.', 'error');
input.value = '';
document.getElementById('dropzone-default-modal').style.display = 'block';
document.getElementById('file-info-modal').classList.remove('active');
@@ -738,14 +826,16 @@ function handleVideoSelectModal(input) {
}
if (file.size > maxSize) {
alert('File size exceeds 512MB limit. Please select a smaller video file.');
showToast('File size exceeds 512MB limit.', 'error');
input.value = '';
document.getElementById('dropzone-default-modal').style.display = 'block';
document.getElementById('file-info-modal').classList.remove('active');
return;
}
// Auto-fill title from filename (like create page)
setAudioMode(audio);
// Auto-fill title from filename
const fileNameWithoutExt = file.name.replace(/\.[^/.]+$/, '').replace(/[-_]/g, ' ');
document.getElementById('upload-video-title').value = fileNameWithoutExt;
@@ -763,14 +853,26 @@ function removeVideoModal(e) {
document.getElementById('dropzone-default-modal').style.display = 'block';
document.getElementById('file-info-modal').classList.remove('active');
document.getElementById('upload-video-title').value = '';
setAudioMode(false);
}
// Thumbnail Dropzone
const thumbnailDropzoneModal = document.getElementById('thumbnail-dropzone-modal');
const thumbnailInputModal = document.getElementById('thumbnail-modal');
let thumbnailInputModal = document.getElementById('thumbnail-modal');
thumbnailInputModal.addEventListener('change', function() {
handleThumbnailSelectModal(this);
let _thumbClickTsModal = 0;
thumbnailInputModal.addEventListener('change', function() { _thumbClickTsModal = 0; handleThumbnailSelectModal(this); });
thumbnailDropzoneModal.addEventListener('click', function(e) {
if (e.target.closest('.btn-remove-file')) return;
const now = Date.now();
if (now - _thumbClickTsModal < 800) return;
_thumbClickTsModal = now;
if (typeof window.openCropperModal_thumb_upload === 'function') {
window.openCropperModal_thumb_upload();
} else {
thumbnailInputModal.click();
}
});
thumbnailDropzoneModal.addEventListener('dragover', (e) => {
@@ -786,8 +888,14 @@ thumbnailDropzoneModal.addEventListener('drop', (e) => {
e.preventDefault();
thumbnailDropzoneModal.classList.remove('dragover');
if (e.dataTransfer.files.length) {
thumbnailInputModal.files = e.dataTransfer.files;
handleThumbnailSelectModal(thumbnailInputModal);
const droppedFile = e.dataTransfer.files[0];
if (typeof window.tcPreload_thumb_upload === 'function') {
window.tcPreload_thumb_upload(droppedFile);
window.openCropperModal_thumb_upload();
} else {
thumbnailInputModal.files = e.dataTransfer.files;
handleThumbnailSelectModal(thumbnailInputModal);
}
}
});
@@ -812,11 +920,69 @@ function handleThumbnailSelectModal(input) {
function removeThumbnailModal(e) {
e.preventDefault();
e.stopPropagation();
thumbnailInputModal.value = '';
const fresh = thumbnailInputModal.cloneNode(false);
thumbnailInputModal.parentNode.replaceChild(fresh, thumbnailInputModal);
thumbnailInputModal = fresh;
thumbnailInputModal.addEventListener('change', function() { handleThumbnailSelectModal(this); });
document.getElementById('thumbnail-default-modal').style.display = 'block';
document.getElementById('thumbnail-info-modal').classList.remove('active');
}
// Slides dropzone (audio mode)
const slidesDropzoneModal = document.getElementById('slides-dropzone-modal');
let slidesInputModal = document.getElementById('slides-modal');
function handleSlidesSelectModal(input) {
if (!input.files || !input.files.length) return;
const files = Array.from(input.files);
const strip = document.getElementById('slides-strip-modal');
strip.innerHTML = '';
files.forEach(f => {
const reader = new FileReader();
reader.onload = e => {
const img = document.createElement('img');
img.src = e.target.result;
img.style.cssText = 'width:56px;height:56px;object-fit:cover;border-radius:6px;border:1px solid rgba(255,255,255,.15);';
strip.appendChild(img);
};
reader.readAsDataURL(f);
});
const count = files.length;
document.getElementById('slides-count-modal').textContent =
count === 1 ? '1 image — will show as static cover' : count + ' images — will crossfade during playback';
document.getElementById('slides-default-modal').style.display = 'none';
document.getElementById('slides-preview-modal').style.display = '';
slidesDropzoneModal.style.borderColor = '#22c55e';
}
slidesDropzoneModal.addEventListener('click', function(e) {
if (e.target.closest('button')) return;
slidesInputModal.click();
});
slidesInputModal.addEventListener('change', function() { handleSlidesSelectModal(this); });
slidesDropzoneModal.addEventListener('dragover', e => { e.preventDefault(); slidesDropzoneModal.classList.add('dragover'); });
slidesDropzoneModal.addEventListener('dragleave', () => slidesDropzoneModal.classList.remove('dragover'));
slidesDropzoneModal.addEventListener('drop', e => {
e.preventDefault();
slidesDropzoneModal.classList.remove('dragover');
if (e.dataTransfer.files.length) {
slidesInputModal.files = e.dataTransfer.files;
handleSlidesSelectModal(slidesInputModal);
}
});
function removeSlidesModal(e) {
e.preventDefault();
e.stopPropagation();
const fresh = slidesInputModal.cloneNode(false);
slidesInputModal.parentNode.replaceChild(fresh, slidesInputModal);
slidesInputModal = fresh;
slidesInputModal.addEventListener('change', function() { handleSlidesSelectModal(this); });
document.getElementById('slides-default-modal').style.display = 'block';
document.getElementById('slides-preview-modal').style.display = 'none';
slidesDropzoneModal.style.borderColor = '#e61e1e';
}
// Type options
document.querySelectorAll('#type-options-modal .option-item').forEach(item => {
item.querySelector('input').addEventListener('change', function() {
@@ -833,6 +999,14 @@ document.querySelectorAll('#visibility-options-modal .option-item').forEach(item
});
});
// Download options
document.querySelectorAll('#download-options-modal .option-item').forEach(item => {
item.querySelector('input').addEventListener('change', function() {
document.querySelectorAll('#download-options-modal .option-item').forEach(o => o.classList.remove('active'));
item.classList.add('active');
});
});
// Form submission
document.getElementById('upload-form-modal').addEventListener('submit', function(e) {
e.preventDefault();
@@ -847,10 +1021,19 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
// Validate title
const title = document.getElementById('upload-video-title').value.trim();
if (!title) {
showErrorModal('Please enter a title for your video');
showErrorModal('Please enter a title');
return;
}
// At least one slide image is required for audio uploads
if (isAudioFile(videoInput.files[0])) {
if (!slidesInputModal.files || !slidesInputModal.files.length) {
showErrorModal('At least one cover image is required for audio tracks');
document.getElementById('slides-dropzone-modal').style.borderColor = '#e61e1e';
return;
}
}
const formData = new FormData(this);
const xhr = new XMLHttpRequest();
@@ -895,9 +1078,17 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
} else {
try {
const response = JSON.parse(xhr.responseText);
showErrorModal(response.message || `Server error (${xhr.status})`);
// Laravel validation errors come back as { errors: { field: ['msg'] } }
if (response.errors) {
const msgs = Object.entries(response.errors)
.map(([field, errs]) => `<b>${field}:</b> ${errs.join(', ')}`)
.join('<br>');
showErrorModal(msgs);
} else {
showErrorModal(response.message || `Server error (${xhr.status})`);
}
} catch(e) {
showErrorModal(`Upload failed with status ${xhr.status}`);
showErrorModal(`Upload failed (${xhr.status}): ${xhr.responseText.slice(0, 300)}`);
}
}
});
@@ -927,3 +1118,14 @@ function showErrorModal(message) {
document.getElementById('submit-btn-modal').innerHTML = '<i class="bi bi-cloud-arrow-up-fill"></i> Upload Video';
}
</script>
<x-image-cropper
id="thumb_upload"
:width="448"
:height="252"
shape="square"
target-input="thumbnail-modal"
preview-img="thumbnail-preview-img"
output-width="1280"
title="Crop Thumbnail"
/>
@@ -0,0 +1,79 @@
@if ($paginator->hasPages())
@once('pagination-styles')
<style>
.pag-nav { display:flex; align-items:center; gap:4px; flex-wrap:wrap; }
.pag-item {
display:inline-flex; align-items:center; justify-content:center;
min-width:32px; height:32px; padding:0 8px;
border-radius:6px;
border:1px solid var(--border, var(--border-color, #252525));
background:var(--bg-card, var(--bg-secondary, #141414));
color:var(--text, var(--text-primary, #f0f0f0));
font-size:13px; font-weight:500; line-height:1;
text-decoration:none; cursor:pointer;
transition:background .15s, border-color .15s, color .15s;
user-select:none;
}
a.pag-item:hover {
background:var(--border-light, var(--border-color, #2e2e2e));
border-color:var(--border-light, var(--border-color, #303030));
color:var(--text, var(--text-primary, #f0f0f0));
}
.pag-item.active {
background:var(--brand, var(--brand-red, #e61e1e));
border-color:var(--brand, var(--brand-red, #e61e1e));
color:#fff;
cursor:default;
}
.pag-item.disabled {
opacity:.35; cursor:default; pointer-events:none;
}
.pag-item.dots {
border-color:transparent; background:transparent;
color:var(--text-2, var(--text-secondary, #888));
pointer-events:none;
}
</style>
@endonce
<nav class="pag-nav" aria-label="Pagination">
{{-- Prev --}}
@if ($paginator->onFirstPage())
<span class="pag-item disabled" aria-disabled="true">
<i class="bi bi-chevron-left" style="font-size:11px;"></i>
</span>
@else
<a class="pag-item" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="Previous">
<i class="bi bi-chevron-left" style="font-size:11px;"></i>
</a>
@endif
{{-- Page numbers --}}
@foreach ($elements as $element)
@if (is_string($element))
<span class="pag-item dots">{{ $element }}</span>
@endif
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<span class="pag-item active" aria-current="page">{{ $page }}</span>
@else
<a class="pag-item" href="{{ $url }}">{{ $page }}</a>
@endif
@endforeach
@endif
@endforeach
{{-- Next --}}
@if ($paginator->hasMorePages())
<a class="pag-item" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="Next">
<i class="bi bi-chevron-right" style="font-size:11px;"></i>
</a>
@else
<span class="pag-item disabled" aria-disabled="true">
<i class="bi bi-chevron-right" style="font-size:11px;"></i>
</span>
@endif
</nav>
@endif
+78 -53
View File
@@ -8,7 +8,28 @@
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding: 12px 24px;
margin: -24px -24px 20px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 56px;
z-index: 90;
}
.playlist-title {
font-size: 20px;
font-weight: 500;
margin: 0;
}
@media (max-width: 768px) {
.playlist-header {
position: relative !important;
top: auto !important;
margin: -16px -16px 16px !important;
padding: 12px 16px !important;
}
}
.playlist-card {
@@ -85,24 +106,6 @@
gap: 16px;
}
.create-playlist-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 24px;
background: var(--brand-red);
color: white;
border: none;
border-radius: 20px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.create-playlist-btn:hover {
background: #cc1a1a;
}
.empty-state {
text-align: center;
@@ -126,12 +129,6 @@
}
@media (max-width: 576px) {
.playlist-header {
flex-direction: column;
gap: 16px;
align-items: flex-start;
}
.playlist-grid {
grid-template-columns: 1fr;
}
@@ -150,8 +147,8 @@
@section('content')
<div class="playlist-header">
<h1 style="font-size: 24px; font-weight: 500;">My Playlists</h1>
<button class="create-playlist-btn" onclick="openCreatePlaylistModal()">
<h1 class="playlist-title">My Playlists</h1>
<button class="action-btn action-btn-primary" onclick="openCreatePlaylistModal()">
<i class="bi bi-plus-lg"></i> New Playlist
</button>
</div>
@@ -161,7 +158,7 @@
<i class="bi bi-collection-play empty-icon"></i>
<h2 class="empty-title">No playlists yet</h2>
<p class="empty-text">Create your first playlist to organize your favorite videos.</p>
<button class="create-playlist-btn" onclick="openCreatePlaylistModal()">
<button class="action-btn action-btn-primary" onclick="openCreatePlaylistModal()">
<i class="bi bi-plus-lg"></i> Create Playlist
</button>
</div>
@@ -250,33 +247,41 @@
onblur="this.style.borderColor = '#3f3f3f';"></textarea>
</div>
<!-- Privacy -->
<!-- Visibility -->
<div class="form-group" style="margin-bottom: 24px;">
<label style="display: flex; align-items: center; gap: 12px; cursor: pointer; padding: 10px 12px; border-radius: 8px; transition: background 0.2s;"
onmouseover="this.style.background='#3f3f3f'"
onmouseout="this.style.background='transparent'">
<input type="checkbox" name="visibility" value="public"
style="width: 20px; height: 20px; accent-color: #e61e1e; cursor: pointer;">
<div style="flex: 1;">
<span style="color: #fff; font-size: 14px; font-weight: 500;">Make playlist public</span>
<div style="color: #aaa; font-size: 12px; margin-top: 2px;">Anyone can search for and view this playlist</div>
</div>
</label>
<label style="display: block; margin-bottom: 10px; font-weight: 500; font-size: 14px; color: #fff;">Visibility</label>
<div style="display: flex; flex-direction: column; gap: 8px;">
<label style="display: flex; align-items: center; gap: 12px; cursor: pointer; padding: 10px 12px; border-radius: 8px; border: 1px solid #3f3f3f; transition: border-color 0.2s; background: #1a1a1a;">
<input type="radio" name="visibility" value="public" style="accent-color: #e61e1e; cursor: pointer; width: 16px; height: 16px;">
<div>
<div style="display: flex; align-items: center; gap: 6px; color: #fff; font-size: 14px; font-weight: 500;"><i class="bi bi-globe"></i> Public</div>
<div style="color: #aaa; font-size: 12px; margin-top: 2px;">Anyone can search for and view this playlist</div>
</div>
</label>
<label style="display: flex; align-items: center; gap: 12px; cursor: pointer; padding: 10px 12px; border-radius: 8px; border: 1px solid #3f3f3f; transition: border-color 0.2s; background: #1a1a1a;">
<input type="radio" name="visibility" value="unlisted" style="accent-color: #e61e1e; cursor: pointer; width: 16px; height: 16px;">
<div>
<div style="display: flex; align-items: center; gap: 6px; color: #fff; font-size: 14px; font-weight: 500;"><i class="bi bi-link-45deg"></i> Unlisted</div>
<div style="color: #aaa; font-size: 12px; margin-top: 2px;">Anyone with the link can view, but it won't appear in search</div>
</div>
</label>
<label style="display: flex; align-items: center; gap: 12px; cursor: pointer; padding: 10px 12px; border-radius: 8px; border: 1px solid #3f3f3f; transition: border-color 0.2s; background: #1a1a1a;">
<input type="radio" name="visibility" value="private" checked style="accent-color: #e61e1e; cursor: pointer; width: 16px; height: 16px;">
<div>
<div style="display: flex; align-items: center; gap: 6px; color: #fff; font-size: 14px; font-weight: 500;"><i class="bi bi-lock-fill"></i> Private</div>
<div style="color: #aaa; font-size: 12px; margin-top: 2px;">Only you can view this playlist</div>
</div>
</label>
</div>
</div>
<!-- Actions -->
<div style="display: flex; gap: 12px; justify-content: flex-end;">
<button type="button" onclick="closeCreatePlaylistModal()"
style="padding: 10px 20px; background: #3f3f3f; color: #fff; border: none; border-radius: 20px; cursor: pointer; font-size: 14px; font-weight: 500; transition: background 0.2s;"
onmouseover="this.style.background='#555'"
onmouseout="this.style.background='#3f3f3f'">
<button type="button" onclick="closeCreatePlaylistModal()" class="action-btn">
Cancel
</button>
<button type="submit"
style="padding: 10px 24px; background: #e61e1e; color: #fff; border: none; border-radius: 20px; cursor: pointer; font-size: 14px; font-weight: 500; transition: background 0.2s;"
onmouseover="this.style.background='#cc1a1a'"
onmouseout="this.style.background='#e61e1e'">
Create
<button type="submit" class="action-btn action-btn-primary">
<i class="bi bi-plus-lg"></i> <span>Create</span>
</button>
</div>
</form>
@@ -316,7 +321,11 @@ const thumbnailImg = document.getElementById('playlist-thumbnail-img');
// Click to upload
thumbnailDropzone.addEventListener('click', function(e) {
if (e.target.closest('button')) return;
thumbnailInput.click();
if (typeof window.openCropperModal_thumb_pl_create === 'function') {
window.openCropperModal_thumb_pl_create();
} else {
thumbnailInput.click();
}
});
// File input change
@@ -343,15 +352,21 @@ thumbnailDropzone.addEventListener('drop', function(e) {
this.style.background = '#1a1a1a';
if (e.dataTransfer.files.length) {
thumbnailInput.files = e.dataTransfer.files;
handleThumbnailSelect(thumbnailInput);
const droppedFile = e.dataTransfer.files[0];
if (typeof window.tcPreload_thumb_pl_create === 'function') {
window.tcPreload_thumb_pl_create(droppedFile);
window.openCropperModal_thumb_pl_create();
} else {
thumbnailInput.files = e.dataTransfer.files;
handleThumbnailSelect(thumbnailInput);
}
}
});
function handleThumbnailSelect(input) {
if (input.files && input.files[0]) {
const file = input.files[0];
const maxSize = 5 * 1024 * 1024; // 5MB
const maxSize = 20 * 1024 * 1024; // 20MB
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
@@ -369,7 +384,7 @@ function handleThumbnailSelect(input) {
}
if (file.size > maxSize) {
showPlaylistToast('File size exceeds 5MB limit. Please select a smaller image.', 'error');
showPlaylistToast('File size exceeds 20MB limit. Please select a smaller image.', 'error');
return;
}
@@ -508,6 +523,16 @@ function showPlaylistToast(message, type = 'success') {
}, 3000);
}
</script>
<x-image-cropper
id="thumb_pl_create"
:width="448"
:height="252"
shape="square"
target-input="playlist-thumbnail-input"
preview-img="playlist-thumbnail-img"
output-width="1280"
title="Crop Playlist Thumbnail"
/>
@endsection
<style>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+66 -141
View File
@@ -3,148 +3,73 @@
@section('title', 'Watch History | TAKEONE')
@section('extra_styles')
<style>
.history-header {
margin-bottom: 24px;
}
.history-title {
font-size: 24px;
font-weight: 500;
}
/* Video Grid */
.yt-video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.yt-video-card {
cursor: pointer;
}
.yt-video-thumb {
position: relative;
aspect-ratio: 16/9;
border-radius: 12px;
overflow: hidden;
background: #1a1a1a;
}
.yt-video-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.yt-video-duration {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0,0,0,0.8);
color: white;
padding: 3px 6px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.yt-video-info {
display: flex;
margin-top: 12px;
gap: 12px;
}
.yt-channel-icon {
width: 36px;
height: 36px;
border-radius: 50%;
background: #555;
flex-shrink: 0;
}
.yt-video-details {
flex: 1;
}
.yt-video-title {
font-size: 16px;
font-weight: 500;
color: var(--text-primary);
margin: 0 0 4px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.3;
}
.yt-video-title a {
color: inherit;
text-decoration: none;
}
.yt-channel-name, .yt-video-meta {
color: var(--text-secondary);
font-size: 14px;
}
/* Empty State */
.yt-empty {
text-align: center;
padding: 80px 20px;
}
.yt-empty-icon {
font-size: 80px;
color: var(--text-secondary);
}
.yt-empty-title {
font-size: 24px;
margin: 20px 0 8px;
}
.yt-empty-text {
color: var(--text-secondary);
margin-bottom: 20px;
}
@media (max-width: 768px) {
.yt-video-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 480px) {
.yt-video-grid {
grid-template-columns: 1fr;
}
}
</style>
<style>
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
gap: 12px;
flex-wrap: wrap;
}
.history-title {
font-size: 22px;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 10px;
}
.history-count {
font-size: 13px;
color: var(--text-secondary);
font-weight: 400;
}
.yt-video-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
@media (max-width: 1100px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 600px) { .yt-video-grid { grid-template-columns: 1fr; } }
@media (min-width: 1500px) { .yt-video-grid { grid-template-columns: repeat(4, 1fr); } }
</style>
@endsection
@section('content')
<div class="history-header">
<h1 class="history-title">Watch History</h1>
</div>
@if($videos->isEmpty())
<div class="yt-empty">
<i class="bi bi-clock-history yt-empty-icon"></i>
<h2 class="yt-empty-title">No watch history</h2>
<p class="yt-empty-text">Videos you watch will appear here.</p>
<a href="{{ route('videos.index') }}" class="btn btn-primary" style="background: var(--brand-red); color: white; padding: 10px 20px; border-radius: 20px; text-decoration: none;">
<i class="bi bi-play-btn"></i> Browse Videos
</a>
</div>
@else
<div class="yt-video-grid">
@foreach($videos as $video)
<x-video-card :video="$video" size="small" />
@endforeach
</div>
@endif
@endsection
<div class="history-header">
<h1 class="history-title">
<i class="bi bi-clock-history" style="color:var(--brand-red);font-size:20px;"></i>
Watch History
@if($videos->isNotEmpty())
<span class="history-count">{{ $videos->count() }} {{ Str::plural('video', $videos->count()) }}</span>
@endif
</h1>
@if($videos->isNotEmpty())
<form method="POST" action="{{ route('history.clear') }}"
onsubmit="return showConfirm('Clear your entire watch history? This cannot be undone.', function(){ document.getElementById('clearHistoryForm').submit(); }, 'Clear'), false"
id="clearHistoryForm">
@csrf
@method('DELETE')
<button type="submit" class="action-btn action-btn-danger">
<i class="bi bi-trash3"></i>
<span>Clear History</span>
</button>
</form>
@endif
</div>
@if($videos->isEmpty())
<div style="text-align:center;padding:80px 20px;color:var(--text-secondary);">
<i class="bi bi-clock-history" style="font-size:64px;display:block;margin-bottom:16px;opacity:.35;"></i>
<h2 style="font-size:20px;font-weight:600;margin:0 0 8px;">No watch history</h2>
<p style="margin:0 0 20px;">Videos you watch will appear here.</p>
<a href="{{ route('videos.index') }}" class="action-btn action-btn-primary">
<i class="bi bi-play-btn-fill"></i> <span>Browse Videos</span>
</a>
</div>
@else
<div class="yt-video-grid">
@foreach($videos as $video)
@include('components.video-card', ['video' => $video])
@endforeach
</div>
@endif
@endsection
+1 -1
View File
@@ -146,7 +146,7 @@
@endforeach
</div>
<div class="mt-4">{{ $videos->links() }}</div>
<div class="mt-4">{{ $videos->onEachSide(1)->links() }}</div>
@endif
@endsection
+571 -127
View File
@@ -3,113 +3,409 @@
@section('title', 'Profile | TAKEONE')
@section('extra_styles')
<style>
.profile-header { background: var(--bg-secondary); border-radius: 12px; padding: 32px; margin-bottom: 24px; position: relative; overflow: hidden; }
.profile-banner { position: absolute; top: 0; left: 0; right: 0; height: 120px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); z-index: 0; }
.profile-content { position: relative; z-index: 1; margin-top: 60px; }
.profile-avatar { width: 120px; height: 120px; border-radius: 50%; object-fit: cover; border: 4px solid var(--bg-secondary); margin-bottom: 16px; }
.profile-name { font-size: 28px; font-weight: 600; margin-bottom: 4px; }
.profile-username { color: var(--text-secondary); margin-bottom: 12px; font-size: 14px; }
.profile-bio { color: var(--text-primary); margin-bottom: 16px; max-width: 600px; line-height: 1.6; }
.profile-meta { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 20px; color: var(--text-secondary); font-size: 14px; }
.profile-meta-item { display: flex; align-items: center; gap: 6px; }
.profile-stats { display: flex; gap: 32px; margin-top: 16px; padding-top: 20px; border-top: 1px solid var(--border-color); }
.profile-stat { text-align: center; }
.profile-stat-value { font-size: 22px; font-weight: 700; }
.profile-stat-label { font-size: 13px; color: var(--text-secondary); }
.social-links { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
.social-link { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: var(--bg-primary); color: var(--text-primary); text-decoration: none; transition: all 0.2s; font-size: 18px; }
.social-link:hover { transform: translateY(-2px); background: var(--brand-red); color: white; }
.social-link.twitter:hover { background: #1da1f2; }
.social-link.instagram:hover { background: #e4405f; }
.social-link.facebook:hover { background: #1877f2; }
.social-link.youtube:hover { background: #ff0000; }
.social-link.linkedin:hover { background: #0077b5; }
.social-link.tiktok:hover { background: #000000; }
.form-card { background: var(--bg-secondary); border-radius: 12px; padding: 24px; margin-bottom: 24px; }
.form-title { font-size: 18px; font-weight: 600; margin-bottom: 20px; padding-bottom: 12px; border-bottom: 1px solid var(--border-color); display: flex; align-items: center; gap: 8px; }
.form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
.form-group { margin-bottom: 16px; }
.form-label { display: block; margin-bottom: 8px; font-weight: 500; font-size: 14px; }
.form-input, .form-textarea { width: 100%; background: #121212; border: 1px solid var(--border-color); border-radius: 8px; padding: 12px 16px; color: var(--text-primary); font-size: 14px; transition: border-color 0.2s; }
.form-input:focus, .form-textarea:focus { outline: none; border-color: var(--brand-red); }
.form-textarea { min-height: 100px; resize: vertical; }
.form-hint { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
.btn-primary { background: var(--brand-red); color: white; border: none; padding: 12px 32px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: background 0.2s; display: inline-flex; align-items: center; gap: 8px; }
.btn-primary:hover { background: #cc1a1a; }
.btn-secondary { background: transparent; color: var(--text-primary); border: 1px solid var(--border-color); padding: 12px 24px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; display: inline-flex; align-items: center; gap: 8px; text-decoration: none; }
.btn-secondary:hover { background: var(--bg-secondary); border-color: var(--text-secondary); }
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; }
.alert-success { background: rgba(34, 197, 94, 0.15); border: 1px solid #22c55e; color: #22c55e; }
.section-title { font-size: 20px; font-weight: 600; margin-bottom: 20px; display: flex; align-items: center; gap: 8px; }
.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 20px; }
.empty-state { text-align: center; padding: 40px 20px; color: var(--text-secondary); }
.empty-state i { font-size: 48px; margin-bottom: 16px; opacity: 0.5; }
.w-100 { width: 100%; }
.mt-4 { margin-top: 24px; }
.text-center { text-align: center; }
.gap-3 { gap: 12px; }
.d-flex { display: flex; }
.flex-column { flex-direction: column; }
.video-card { background: var(--bg-primary); border-radius: 8px; overflow: hidden; transition: transform 0.2s; }
.video-card:hover { transform: translateY(-4px); }
.video-thumbnail { position: relative; aspect-ratio: 16/9; background: #000; }
.video-thumbnail img { width: 100%; height: 100%; object-fit: cover; }
.video-duration { position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,0.8); color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; }
.video-info { padding: 12px; }
.video-title { font-size: 14px; font-weight: 500; margin-bottom: 6px; line-height: 1.4; }
.video-title a { color: var(--text-primary); text-decoration: none; }
.video-title a:hover { color: var(--brand-red); }
.video-meta { font-size: 12px; color: var(--text-secondary); }
@media (max-width: 768px) { .profile-stats { justify-content: center; } .profile-meta { justify-content: center; } .social-links { justify-content: center; } }
</style>
<style>
/* ── Profile Header ─────────────────────────────────────────────────────── */
.profile-header {
background: var(--bg-secondary);
border-radius: 16px;
overflow: hidden;
margin-bottom: 28px;
box-shadow: 0 4px 24px rgba(0,0,0,.25);
}
.profile-banner {
height: 160px;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 40%, #0f3460 70%, #533483 100%);
position: relative;
}
.profile-banner-edit {
position: absolute;
top: 14px; right: 16px;
display: flex; align-items: center; gap: 6px;
padding: 7px 14px;
background: rgba(0,0,0,.45);
backdrop-filter: blur(6px);
border: 1px solid rgba(255,255,255,.15);
border-radius: 20px;
color: #fff;
font-size: 13px; font-weight: 500; font-family: inherit;
cursor: pointer; transition: background .2s, border-color .2s;
text-decoration: none;
}
.profile-banner-edit:hover { background: rgba(230,30,30,.7); border-color: rgba(230,30,30,.6); color: #fff; }
.profile-banner-edit i { font-size: 13px; }
.profile-content {
padding: 0 28px 28px;
text-align: center;
}
.profile-avatar-wrap {
display: inline-block;
position: relative;
margin-top: -52px;
margin-bottom: 14px;
}
.profile-avatar {
width: 108px; height: 108px;
border-radius: 50%;
object-fit: cover;
border: 4px solid var(--bg-secondary);
box-shadow: 0 6px 28px rgba(0,0,0,.5);
display: block;
}
.profile-name {
font-size: 24px; font-weight: 700;
letter-spacing: -.3px;
margin: 0 0 4px;
color: var(--text-primary);
}
.profile-username {
font-size: 14px; color: var(--text-secondary);
margin: 0 0 12px;
}
.profile-bio {
font-size: 14px; line-height: 1.7;
color: var(--text-secondary);
max-width: 480px;
margin: 0 auto 16px;
}
.profile-meta {
display: flex; flex-wrap: wrap;
align-items: center; justify-content: center;
gap: 6px 10px;
margin-bottom: 20px;
}
.profile-meta-item {
display: flex; align-items: center; gap: 5px;
font-size: 13px; color: var(--text-secondary);
background: rgba(255,255,255,.05);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 4px 10px;
}
.profile-meta-item i { font-size: 12px; }
.profile-meta-item a { color: inherit; text-decoration: none; }
.profile-meta-item a:hover { color: var(--text-primary); }
.profile-divider { height: 1px; background: var(--border-color); margin: 0 -28px 20px; }
.profile-stats {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
.profile-stat {
padding: 8px 24px;
text-align: center;
border-right: 1px solid var(--border-color);
text-decoration: none; color: inherit;
transition: background .15s;
border-radius: 8px;
}
.profile-stat:last-child { border-right: none; }
.profile-stat:hover { background: rgba(255,255,255,.04); }
.profile-stat-value { display: block; font-size: 20px; font-weight: 700; line-height: 1.2; }
.profile-stat-label { display: block; font-size: 12px; color: var(--text-secondary); margin-top: 2px; text-transform: uppercase; letter-spacing: .5px; }
.social-links { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; padding-top: 4px; }
.social-link {
display: flex; align-items: center; justify-content: center;
width: 36px; height: 36px;
border-radius: 10px;
background: rgba(255,255,255,.06);
border: 1px solid var(--border-color);
color: var(--text-secondary);
text-decoration: none;
font-size: 16px;
transition: all .2s;
}
.social-link:hover { transform: translateY(-2px); color: #fff; border-color: transparent; background: var(--brand-red); }
.social-link.twitter:hover { background: #1da1f2; }
.social-link.instagram:hover { background: linear-gradient(45deg,#f09433,#e6683c,#dc2743,#cc2366,#bc1888); }
.social-link.facebook:hover { background: #1877f2; }
.social-link.youtube:hover { background: #ff0000; }
.social-link.linkedin:hover { background: #0077b5; }
.social-link.tiktok:hover { background: #010101; }
.social-link.whatsapp:hover { background: #25d366; }
.social-link.googlemap:hover { background: #ea4335; }
/* ── Page cards ─────────────────────────────────────────────────────────── */
.form-card { background: var(--bg-secondary); border-radius: 12px; padding: 24px; margin-bottom: 24px; }
.form-title { font-size: 16px; font-weight: 600; margin-bottom: 20px; padding-bottom: 12px; border-bottom: 1px solid var(--border-color); display: flex; align-items: center; gap: 8px; }
.form-group { margin-bottom: 16px; }
.form-label { display: block; margin-bottom: 8px; font-weight: 500; font-size: 14px; }
.form-input, .form-textarea { width: 100%; background: #121212; border: 1px solid var(--border-color); border-radius: 8px; padding: 11px 14px; color: var(--text-primary); font-size: 14px; transition: border-color .2s; }
.form-input:focus, .form-textarea:focus { outline: none; border-color: var(--brand-red); }
.form-textarea { min-height: 90px; resize: vertical; }
.form-hint { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
.section-title { font-size: 18px; font-weight: 600; margin-bottom: 20px; display: flex; align-items: center; gap: 8px; }
/* ── Quick links sidebar ─────────────────────────────────────────────────── */
.quick-link {
display: flex; align-items: center; gap: 12px;
padding: 12px 14px; border-radius: 10px;
background: rgba(255,255,255,.03);
border: 1px solid var(--border-color);
color: var(--text-primary); text-decoration: none;
font-size: 14px; font-weight: 500;
transition: background .15s, border-color .15s, transform .15s;
margin-bottom: 8px;
}
.quick-link:last-child { margin-bottom: 0; }
.quick-link i { font-size: 16px; color: var(--text-secondary); width: 20px; text-align: center; transition: color .15s; }
.quick-link:hover { background: rgba(230,30,30,.08); border-color: rgba(230,30,30,.3); transform: translateX(3px); }
.quick-link:hover i { color: var(--brand-red); }
/* ── Videos ─────────────────────────────────────────────────────────────── */
.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
.video-card { background: var(--bg-primary); border-radius: 10px; overflow: hidden; transition: transform .2s, box-shadow .2s; }
.video-card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0,0,0,.3); }
.video-thumbnail { position: relative; aspect-ratio: 16/9; background: #000; }
.video-thumbnail img { width: 100%; height: 100%; object-fit: cover; }
.video-duration { position: absolute; bottom: 6px; right: 6px; background: rgba(0,0,0,.8); color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 11px; font-weight: 500; }
.video-info { padding: 12px; }
.video-title { font-size: 13px; font-weight: 500; margin-bottom: 6px; line-height: 1.4; }
.video-title a { color: var(--text-primary); text-decoration: none; }
.video-title a:hover { color: var(--brand-red); }
.video-meta { font-size: 12px; color: var(--text-secondary); }
.empty-state { text-align: center; padding: 40px 20px; color: var(--text-secondary); }
.empty-state i { font-size: 44px; display: block; margin-bottom: 12px; opacity: .4; }
.w-100 { width: 100%; }
.mt-4 { margin-top: 24px; }
.d-flex { display: flex; }
.flex-column { flex-direction: column; }
/* ── Edit Profile Modal ─────────────────────────────────────────────────── */
.ep-overlay {
display: none; position: fixed; inset: 0; z-index: 2000;
background: rgba(0,0,0,.75);
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
align-items: center; justify-content: center;
padding: 20px 16px;
}
.ep-overlay.is-open { display: flex; }
.ep-dialog {
display: flex; flex-direction: column;
background: var(--bg-secondary, #1e1e1e);
border: 1px solid var(--border-color, #303030);
border-radius: 16px;
width: 100%; max-width: 600px;
max-height: min(88vh, 720px);
box-shadow: 0 32px 80px rgba(0,0,0,.7);
animation: epIn .2s ease;
}
@keyframes epIn {
from { opacity: 0; transform: scale(.97) translateY(-8px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.ep-header {
flex-shrink: 0;
display: flex; align-items: center; justify-content: space-between;
padding: 18px 20px 0;
}
.ep-header-title { font-size: 16px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.ep-close {
display: flex; align-items: center; justify-content: center;
width: 30px; height: 30px; border-radius: 50%;
border: none; background: transparent;
color: var(--text-secondary, #aaa); font-size: 16px; cursor: pointer;
transition: background .15s, color .15s;
}
.ep-close:hover { background: rgba(255,255,255,.1); color: var(--text-primary, #f1f1f1); }
.ep-tabs {
flex-shrink: 0;
display: flex; gap: 2px;
padding: 12px 20px 0;
border-bottom: 1px solid var(--border-color, #303030);
}
.ep-tab {
display: flex; align-items: center; gap: 6px;
padding: 8px 14px;
border: none; background: transparent;
color: var(--text-secondary, #aaa);
font-size: 13px; font-weight: 500; font-family: inherit;
cursor: pointer; border-radius: 8px 8px 0 0;
border-bottom: 2px solid transparent; margin-bottom: -1px;
transition: color .15s, border-color .15s, background .15s;
white-space: nowrap;
}
.ep-tab:hover { color: var(--text-primary, #f1f1f1); background: rgba(255,255,255,.05); }
.ep-tab.active { color: var(--brand-red, #e61e1e); border-bottom-color: var(--brand-red, #e61e1e); }
.ep-tab i { font-size: 13px; }
.ep-body {
flex: 1; min-height: 0;
overflow-y: auto;
padding: 22px 20px;
scrollbar-width: thin;
scrollbar-color: var(--border-color, #303030) transparent;
}
.ep-body::-webkit-scrollbar { width: 4px; }
.ep-body::-webkit-scrollbar-thumb { background: var(--border-color, #303030); border-radius: 2px; }
.ep-panel { display: none; }
.ep-panel.active { display: block; }
/* Form must be a flex child so it respects the dialog's max-height */
#epForm {
display: flex; flex-direction: column;
flex: 1; min-height: 0; overflow: hidden;
}
.ep-footer {
flex-shrink: 0;
padding: 14px 20px 18px;
border-top: 1px solid var(--border-color, #303030);
display: flex; justify-content: flex-end; gap: 8px;
}
.ep-2col { display: flex; gap: 12px; }
.ep-2col > .form-group { flex: 1; min-width: 0; margin-bottom: 0; }
/* Match dropdown button height to .form-input (padding: 11px 14px, no min-height) */
.ep-body .csd-btn { padding: 11px 14px; min-height: 0; }
.ep-body .dp-btn { padding: 11px 10px; min-height: 0; }
/* Avatar upload */
.avatar-upload-area { display: flex; flex-direction: column; align-items: center; gap: 20px; padding: 24px 0 8px; }
.avatar-preview-wrap { position: relative; width: 120px; height: 120px; }
.avatar-preview { width: 120px; height: 120px; border-radius: 50%; object-fit: cover; border: 3px solid var(--border-color, #303030); display: block; }
.avatar-edit-btn {
position: absolute; bottom: 2px; right: 2px;
width: 32px; height: 32px; border-radius: 50%;
background: var(--brand-red, #e61e1e);
border: 2px solid var(--bg-secondary, #1e1e1e);
color: #fff; font-size: 13px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background .15s;
}
.avatar-edit-btn:hover { background: #c01717; }
.avatar-file-input { display: none; }
.avatar-upload-hint { font-size: 13px; color: var(--text-secondary, #aaa); text-align: center; line-height: 1.7; }
.avatar-upload-hint strong { color: var(--text-primary, #f1f1f1); }
/* Toast */
.ep-toast {
position: fixed; bottom: 80px; left: 50%;
transform: translateX(-50%) translateY(16px);
background: #22c55e; color: #fff;
font-size: 14px; font-weight: 500;
padding: 10px 20px; border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,.4);
z-index: 3000; opacity: 0; pointer-events: none;
transition: opacity .25s, transform .25s;
white-space: nowrap;
}
.ep-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
@media (max-width: 768px) {
.profile-banner { height: 120px; }
.profile-content { padding: 0 18px 22px; }
.profile-stat { padding: 8px 14px; }
.profile-stat-value { font-size: 18px; }
.ep-overlay { align-items: flex-end; padding: 0; }
.ep-dialog { border-radius: 16px 16px 0 0; max-width: 100%; max-height: 92vh; }
}
</style>
@endsection
@php
// If re-displaying after a validation error, rebuild from old() input
$oldLinks = old('slink');
if ($oldLinks) {
$socialExisting = collect($oldLinks)
->filter(fn($e) => !empty($e['platform']) && !empty($e['value']))
->map(fn($e) => ['platform' => $e['platform'], 'value' => $e['value'], 'visibility' => $e['visibility'] ?? 'public'])
->values()->all();
} else {
$socialExisting = $user->socialLinks()->orderBy('sort_order')->get()
->map(fn($l) => ['platform' => $l->platform, 'value' => $l->value, 'visibility' => $l->visibility])
->all();
}
@endphp
@section('content')
<div class="profile-header">
<div class="profile-banner"></div>
<div class="profile-content text-center">
@if($user->avatar)
<img src="{{ asset('storage/avatars/' . $user->avatar) }}" alt="{{ $user->name }}" class="profile-avatar">
@else
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" alt="{{ $user->name }}" class="profile-avatar">
@endif
<div class="profile-banner">
<button type="button" class="profile-banner-edit" id="openEditProfile">
<i class="bi bi-pencil-square"></i> Edit Profile
</button>
</div>
<div class="profile-content">
<div class="profile-avatar-wrap">
@if($user->avatar)
<img src="{{ asset('storage/avatars/' . $user->avatar) }}" alt="{{ $user->name }}" class="profile-avatar" id="pageAvatar">
@else
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" alt="{{ $user->name }}" class="profile-avatar" id="pageAvatar">
@endif
</div>
<h1 class="profile-name">{{ $user->name }}</h1>
@if($user->username)
<p class="profile-username">@ {{ $user->username }}</p>
<p class="profile-username">@{{ $user->username }}</p>
@endif
@if($user->bio)
<p class="profile-bio">{{ $user->bio }}</p>
@endif
<div class="profile-meta justify-content-center">
<div class="profile-meta">
@if($user->location)
<div class="profile-meta-item"><i class="bi bi-geo-alt"></i><span>{{ $user->location }}</span></div>
@endif
@if($user->website)
<div class="profile-meta-item"><i class="bi bi-link-45deg"></i><a href="{{ $user->website_url }}" target="_blank" style="color: inherit;">{{ Str::limit($user->website, 30) }}</a></div>
<div class="profile-meta-item"><i class="bi bi-link-45deg"></i><a href="{{ $user->website_url }}" target="_blank">{{ Str::limit($user->website, 30) }}</a></div>
@endif
<div class="profile-meta-item"><i class="bi bi-calendar3"></i><span>Joined {{ $user->created_at->format('M Y') }}</span></div>
</div>
@if($user->hasSocialLinks())
<div class="social-links justify-content-center">
@if($user->twitter)<a href="https://twitter.com/{{ $user->twitter }}" target="_blank" class="social-link twitter" title="Twitter"><i class="bi bi-twitter-x"></i></a>@endif
@if($user->instagram)<a href="https://instagram.com/{{ $user->instagram }}" target="_blank" class="social-link instagram" title="Instagram"><i class="bi bi-instagram"></i></a>@endif
@if($user->facebook)<a href="https://facebook.com/{{ $user->facebook }}" target="_blank" class="social-link facebook" title="Facebook"><i class="bi bi-facebook"></i></a>@endif
@if($user->youtube)<a href="https://youtube.com/@{{ $user->youtube }}" target="_blank" class="social-link youtube" title="YouTube"><i class="bi bi-youtube"></i></a>@endif
@if($user->linkedin)<a href="https://linkedin.com/in/{{ $user->linkedin }}" target="_blank" class="social-link linkedin" title="LinkedIn"><i class="bi bi-linkedin"></i></a>@endif
@if($user->tiktok)<a href="https://tiktok.com/@{{ $user->tiktok }}" target="_blank" class="social-link tiktok" title="TikTok"><i class="bi bi-tiktok"></i></a>@endif
@if($user->website)<a href="{{ $user->website_url }}" target="_blank" class="social-link" title="Website"><i class="bi bi-globe"></i></a>@endif
@php
$socialIconMap = [
'twitter' => ['icon' => 'bi-twitter-x', 'class' => 'twitter', 'href' => fn($v) => "https://twitter.com/{$v}"],
'instagram' => ['icon' => 'bi-instagram', 'class' => 'instagram', 'href' => fn($v) => "https://instagram.com/{$v}"],
'facebook' => ['icon' => 'bi-facebook', 'class' => 'facebook', 'href' => fn($v) => "https://facebook.com/{$v}"],
'youtube' => ['icon' => 'bi-youtube', 'class' => 'youtube', 'href' => fn($v) => "https://youtube.com/@{$v}"],
'linkedin' => ['icon' => 'bi-linkedin', 'class' => 'linkedin', 'href' => fn($v) => "https://linkedin.com/in/{$v}"],
'tiktok' => ['icon' => 'bi-tiktok', 'class' => 'tiktok', 'href' => fn($v) => "https://tiktok.com/@{$v}"],
'whatsapp' => ['icon' => 'bi-whatsapp', 'class' => 'whatsapp', 'href' => fn($v) => "https://wa.me/" . preg_replace('/\D/', '', $v)],
'website' => ['icon' => 'bi-globe', 'class' => '', 'href' => fn($v) => preg_match('/^https?:\/\//', $v) ? $v : "https://{$v}"],
'google_location' => ['icon' => 'bi-geo-alt-fill', 'class' => 'googlemap', 'href' => fn($v) => $v],
'social_phone' => ['icon' => 'bi-telephone-fill', 'class' => '', 'href' => fn($v) => "tel:{$v}"],
'social_email' => ['icon' => 'bi-envelope-fill', 'class' => '', 'href' => fn($v) => "mailto:{$v}"],
];
@endphp
<div class="social-links">
@foreach($user->socialLinks as $link)
@if(isset($socialIconMap[$link->platform]))
@php $m = $socialIconMap[$link->platform]; @endphp
<a href="{{ $m['href']($link->value) }}"
target="{{ in_array($link->platform, ['social_phone','social_email']) ? '_self' : '_blank' }}"
class="social-link {{ $m['class'] }}"
title="{{ $link->value }}">
<i class="bi {{ $m['icon'] }}"></i>
</a>
@endif
@endforeach
</div>
@endif
<div class="profile-stats justify-content-center">
<a href="{{ route('channel', $user->id) }}" class="profile-stat"><div class="profile-stat-value">{{ $user->videos->count() }}</div><div class="profile-stat-label">Videos</div></a>
<div class="profile-stat"><div class="profile-stat-value">{{ number_format($user->subscriber_count) }}</div><div class="profile-stat-label">Subscribers</div></div>
<div class="profile-stat"><div class="profile-stat-value">{{ \DB::table('video_views')->whereIn('video_id', $user->videos->pluck('id'))->count() }}</div><div class="profile-stat-label">Views</div></div>
<div class="profile-stat"><div class="profile-stat-value">{{ $user->likes->count() }}</div><div class="profile-stat-label">Likes</div></div>
<div class="profile-divider"></div>
<div class="profile-stats">
<a href="{{ route('channel', $user->channel) }}" class="profile-stat">
<span class="profile-stat-value">{{ $user->videos->count() }}</span>
<span class="profile-stat-label">Videos</span>
</a>
<div class="profile-stat">
<span class="profile-stat-value">{{ number_format($user->subscriber_count) }}</span>
<span class="profile-stat-label">Subscribers</span>
</div>
<div class="profile-stat">
<span class="profile-stat-value">{{ \DB::table('video_views')->whereIn('video_id', $user->videos->pluck('id'))->count() }}</span>
<span class="profile-stat-label">Views</span>
</div>
<div class="profile-stat">
<span class="profile-stat-value">{{ $user->likes->count() }}</span>
<span class="profile-stat-label">Likes</span>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="form-card">
@@ -132,59 +428,207 @@
@endforeach
</div>
@if($user->videos->count() > 4)
<div class="text-center mt-4"><a href="{{ route('channel', $user->id) }}" class="btn-secondary">View All Videos <i class="bi bi-arrow-right"></i></a></div>
<div class="text-center mt-4"><a href="{{ route('channel', $user->channel) }}" class="action-btn">View All Videos <i class="bi bi-arrow-right"></i></a></div>
@endif
@else
<div class="empty-state"><i class="bi bi-camera-video"></i><p>No videos yet</p></div>
@endif
</div>
</div>
<div class="col-lg-4">
<div class="form-card">
<h2 class="form-title"><i class="bi bi-pencil-square"></i> Edit Profile</h2>
@if(session('success'))<div class="alert alert-success">{{ session('success') }}</div>@endif
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data">
@csrf @method('PUT')
<div class="form-group"><label class="form-label">Name</label><input type="text" name="name" class="form-input" value="{{ old('name', $user->name) }}" required></div>
<div class="form-group"><label class="form-label">Avatar</label><input type="file" name="avatar" class="form-input" accept="image/*"><small class="form-hint">Max: 5MB</small></div>
<div class="form-group"><label class="form-label">Bio</label><textarea name="bio" class="form-textarea" placeholder="Tell us about yourself...">{{ old('bio', $user->bio) }}</textarea></div>
<div class="form-group"><label class="form-label">Location</label><input type="text" name="location" class="form-input" value="{{ old('location', $user->location) }}" placeholder="City, Country"></div>
<div class="form-group"><label class="form-label">Birthday</label><input type="date" name="birthday" class="form-input" value="{{ old('birthday', $user->birthday) }}"></div>
<button type="submit" class="btn-primary w-100"><i class="bi bi-check-lg"></i> Save Changes</button>
</form>
</div>
<div class="form-card">
<h2 class="form-title"><i class="bi bi-share"></i> Social Links</h2>
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data">
@csrf @method('PUT')
<div class="form-row">
<div class="form-group"><label class="form-label"><i class="bi bi-twitter-x"></i> Twitter</label><input type="text" name="twitter" class="form-input" value="{{ old('twitter', $user->twitter) }}" placeholder="username"></div>
<div class="form-group"><label class="form-label"><i class="bi bi-instagram"></i> Instagram</label><input type="text" name="instagram" class="form-input" value="{{ old('instagram', $user->instagram) }}" placeholder="username"></div>
</div>
<div class="form-row">
<div class="form-group"><label class="form-label"><i class="bi bi-facebook"></i> Facebook</label><input type="text" name="facebook" class="form-input" value="{{ old('facebook', $user->facebook) }}" placeholder="username"></div>
<div class="form-group"><label class="form-label"><i class="bi bi-youtube"></i> YouTube</label><input type="text" name="youtube" class="form-input" value="{{ old('youtube', $user->youtube) }}" placeholder="channel"></div>
</div>
<div class="form-row">
<div class="form-group"><label class="form-label"><i class="bi bi-linkedin"></i> LinkedIn</label><input type="text" name="linkedin" class="form-input" value="{{ old('linkedin', $user->linkedin) }}" placeholder="username"></div>
<div class="form-group"><label class="form-label"><i class="bi bi-tiktok"></i> TikTok</label><input type="text" name="tiktok" class="form-input" value="{{ old('tiktok', $user->tiktok) }}" placeholder="username"></div>
</div>
<div class="form-group"><label class="form-label"><i class="bi bi-globe"></i> Website</label><input type="url" name="website" class="form-input" value="{{ old('website', $user->website) }}" placeholder="https://example.com"></div>
<button type="submit" class="btn-primary w-100"><i class="bi bi-check-lg"></i> Save Social Links</button>
</form>
</div>
<div class="form-card">
<h2 class="form-title"><i class="bi bi-link-45deg"></i> Quick Links</h2>
<div class="d-flex flex-column gap-3">
<a href="{{ route('channel', $user->id) }}" class="btn-secondary text-center"><i class="bi bi-play-btn"></i> View My Channel</a>
<a href="{{ route('settings') }}" class="btn-secondary text-center"><i class="bi bi-gear"></i> Account Settings</a>
<a href="{{ route('history') }}" class="btn-secondary text-center"><i class="bi bi-clock-history"></i> Watch History</a>
<a href="{{ route('liked') }}" class="btn-secondary text-center"><i class="bi bi-heart"></i> Liked Videos</a>
</div>
<a href="{{ route('channel', $user->channel) }}" class="quick-link"><i class="bi bi-play-btn"></i><span>View My Channel</span></a>
<a href="{{ route('settings') }}" class="quick-link"><i class="bi bi-gear"></i><span>Account Settings</span></a>
<a href="{{ route('history') }}" class="quick-link"><i class="bi bi-clock-history"></i><span>Watch History</span></a>
<a href="{{ route('liked') }}" class="quick-link"><i class="bi bi-heart"></i><span>Liked Videos</span></a>
</div>
</div>
</div>
{{-- ── Edit Profile Modal ───────────────────────────────────────────── --}}
<div class="ep-overlay" id="epOverlay" role="dialog" aria-modal="true" aria-labelledby="epTitle">
<div class="ep-dialog">
<div class="ep-header">
<span class="ep-header-title" id="epTitle"><i class="bi bi-pencil-square"></i> Edit Profile</span>
<button type="button" class="ep-close" id="closeEditProfile" aria-label="Close"><i class="bi bi-x-lg"></i></button>
</div>
<div class="ep-tabs">
<button type="button" class="ep-tab active" data-tab="info">
<i class="bi bi-person"></i> Profile
</button>
<button type="button" class="ep-tab" data-tab="picture">
<i class="bi bi-camera"></i> Picture
</button>
<button type="button" class="ep-tab" data-tab="social">
<i class="bi bi-share"></i> Social
</button>
</div>
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data" id="epForm">
@csrf @method('PUT')
<div class="ep-body">
{{-- Tab: Profile Info --}}
<div class="ep-panel active" id="panel-info">
<div class="form-group"><label class="form-label">Name</label><input type="text" name="name" class="form-input" value="{{ old('name', $user->name) }}" required></div>
<div class="form-group"><label class="form-label">Bio</label><textarea name="bio" class="form-textarea" placeholder="Tell us about yourself...">{{ old('bio', $user->bio) }}</textarea></div>
<div class="ep-2col" style="margin-bottom:16px;">
<div class="form-group">
<x-date-picker
name="birthday"
label="Birthday"
value="{{ old('birthday', $user->birthday) }}"
:max-year="(int) date('Y')"
:min-year="1900"
/>
</div>
<div class="form-group">
<label class="form-label">Gender</label>
<select name="gender" class="form-input" style="cursor:pointer;">
<option value="">Prefer not to say</option>
<option value="male" {{ old('gender', $user->gender) === 'male' ? 'selected' : '' }}>Male</option>
<option value="female" {{ old('gender', $user->gender) === 'female' ? 'selected' : '' }}>Female</option>
<option value="prefer_not_to_say" {{ old('gender', $user->gender) === 'prefer_not_to_say' ? 'selected' : '' }}>Prefer not to say</option>
</select>
</div>
</div>
<div class="ep-2col" style="margin-bottom:16px;">
<div class="form-group">
<x-country-select
name="nationality"
label="Nationality"
placeholder="Select nationality"
value="{{ old('nationality', $user->nationality) }}"
/>
</div>
<div class="form-group">
<x-timezone-select
name="timezone"
label="Timezone"
placeholder="Select timezone"
value="{{ old('timezone', $user->timezone) }}"
/>
</div>
</div>
</div>
{{-- Tab: Picture --}}
<div class="ep-panel" id="panel-picture">
<div class="avatar-upload-area">
<div class="avatar-preview-wrap">
@if($user->avatar)
<img src="{{ asset('storage/avatars/' . $user->avatar) }}" class="avatar-preview" id="avatarPreview" alt="Avatar">
@else
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" class="avatar-preview" id="avatarPreview" alt="Avatar">
@endif
<button type="button" class="avatar-edit-btn" id="avatarEditBtn" title="Change photo">
<i class="bi bi-camera-fill"></i>
</button>
</div>
<input type="file" name="avatar" id="avatarFileInput" class="avatar-file-input" accept="image/*">
<div class="avatar-upload-hint">
<strong>Click the camera icon</strong> to upload a new photo<br>
JPG, PNG or GIF &nbsp;·&nbsp; Max 5 MB<br>
<small style="color:var(--text-secondary)">Recommended: at least 200 × 200 px</small>
</div>
</div>
</div>
{{-- Tab: Social --}}
<div class="ep-panel" id="panel-social">
<x-social-links-editor :existing="$socialExisting" />
</div>
</div>{{-- .ep-body --}}
<div class="ep-footer">
<button type="button" class="action-btn" id="epCancelBtn"><span>Cancel</span></button>
<button type="submit" class="action-btn action-btn-primary"><i class="bi bi-check-lg"></i> <span>Save Changes</span></button>
</div>
</form>
</div>
</div>
<div class="ep-toast" id="epToast"></div>
@endsection
@section('scripts')
<script>
(function () {
const overlay = document.getElementById('epOverlay');
const openBtn = document.getElementById('openEditProfile');
const closeBtn = document.getElementById('closeEditProfile');
const cancelBtn = document.getElementById('epCancelBtn');
const toast = document.getElementById('epToast');
// ── Modal open / close ───────────────────────────────────────────────
function openModal(tab) {
overlay.classList.add('is-open');
document.body.style.overflow = 'hidden';
if (tab) switchTab(tab);
}
function closeModal() {
overlay.classList.remove('is-open');
document.body.style.overflow = '';
}
openBtn.addEventListener('click', () => openModal('info'));
closeBtn.addEventListener('click', closeModal);
cancelBtn.addEventListener('click', closeModal);
overlay.addEventListener('click', e => { if (e.target === overlay) closeModal(); });
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && overlay.classList.contains('is-open')) closeModal();
});
// ── Tabs ─────────────────────────────────────────────────────────────
function switchTab(name) {
document.querySelectorAll('.ep-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
document.querySelectorAll('.ep-panel').forEach(p => p.classList.toggle('active', p.id === 'panel-' + name));
}
document.querySelectorAll('.ep-tab').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
});
// ── Avatar preview ───────────────────────────────────────────────────
const avatarEditBtn = document.getElementById('avatarEditBtn');
const avatarFileInput = document.getElementById('avatarFileInput');
const avatarPreview = document.getElementById('avatarPreview');
const pageAvatar = document.getElementById('pageAvatar');
avatarEditBtn.addEventListener('click', () => avatarFileInput.click());
avatarFileInput.addEventListener('change', () => {
const file = avatarFileInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
avatarPreview.src = e.target.result;
if (pageAvatar) pageAvatar.src = e.target.result;
};
reader.readAsDataURL(file);
});
// ── Flash handling ───────────────────────────────────────────────────
@if(session('success'))
showToast(@json(session('success')), 'success');
@elseif($errors->any())
openModal('info');
@endif
function showToast(msg, type) {
toast.textContent = msg;
toast.style.background = type === 'success' ? '#22c55e' : '#e61e1e';
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 3500);
}
}());
</script>
@endsection
+6 -22
View File
@@ -48,22 +48,6 @@
border-color: var(--brand-red);
}
.btn-primary {
background: var(--brand-red);
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover {
background: #cc1a1a;
}
.alert {
padding: 12px 16px;
border-radius: 8px;
@@ -128,7 +112,7 @@
<input type="password" name="new_password_confirmation" class="form-input" required>
</div>
<button type="submit" class="btn-primary">Update Password</button>
<button type="submit" class="action-btn action-btn-primary">Update Password</button>
</form>
</div>
@@ -150,12 +134,12 @@
<div class="settings-card">
<h2 class="settings-title">Quick Links</h2>
<a href="{{ route('profile') }}" class="btn-primary text-decoration-none">
<i class="bi bi-person"></i> Edit Profile
<a href="{{ route('profile') }}" class="action-btn action-btn-primary">
<i class="bi bi-person"></i> <span>Edit Profile</span>
</a>
<a href="{{ route('channel', $user->id) }}" class="btn-primary text-decoration-none ms-2">
<i class="bi bi-play-btn"></i> My Channel
<a href="{{ route('channel', $user->channel) }}" class="action-btn action-btn-primary ms-2">
<i class="bi bi-play-btn"></i> <span>My Channel</span>
</a>
</div>
</div>
+335 -113
View File
@@ -11,16 +11,17 @@
@media (max-width: 991px) {
.upload-page-only .yt-header, .upload-page-only .yt-sidebar { display: block !important; }
.upload-page-only .yt-main { display: block !important; margin-left: 0; }
.upload-page-only .yt-main { display: block !important; margin-left: 0; padding-bottom: 80px; }
.upload-page-only.upload-page-responsive { display: block; background: transparent; padding: 0; }
.upload-page-only.upload-page-responsive .upload-page-standalone { max-width: 100%; border-radius: 0; box-shadow: none; background: transparent; border: none; }
.upload-page-only.upload-page-responsive .upload-page-standalone { max-width: 100%; border-radius: 0; box-shadow: none; background: var(--bg-primary); border: none; padding: 16px; }
.upload-page-only.upload-page-responsive .upload-page-header { display: none !important; }
.upload-page-only.upload-page-responsive .upload-page-body { padding: 0; background: transparent; }
.upload-page-only.upload-page-responsive .form-input, .upload-page-only.upload-page-responsive .form-textarea { background: var(--bg-secondary); border-color: var(--border-color); color: var(--text-primary); }
.upload-page-only.upload-page-responsive .form-input,
.upload-page-only.upload-page-responsive .form-textarea { background: var(--bg-secondary); border-color: var(--border-color); color: var(--text-primary); }
.upload-page-only.upload-page-responsive .form-label { color: var(--text-primary); }
.upload-page-only.upload-page-responsive .visibility-content-modal { background: var(--bg-secondary); border-color: var(--border-color); }
.upload-page-only.upload-page-responsive .visibility-title { color: var(--text-primary); }
.upload-page-only.upload-page-responsive .visibility-desc { color: var(--text-secondary); }
#dropzone { padding: 40px 16px; }
.options-grid { flex-direction: column; }
.option-item { min-width: 100%; }
}
@media (min-width: 992px) {
@@ -123,18 +124,11 @@
/* Submit Button */
.btn-submit {
width: 100%;
background: var(--brand-red);
color: white;
border: none;
padding: 14px 24px;
border-radius: 8px;
justify-content: center;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-submit:hover { background: #cc1a1a; }
.btn-submit:disabled { background: #555; cursor: not-allowed; }
.btn-submit:disabled { background: #555 !important; border-color: #555 !important; cursor: not-allowed; opacity: 1; }
@media (max-width: 600px) {
.form-row { grid-template-columns: 1fr; }
@@ -152,16 +146,16 @@
<!-- Video File Dropzone -->
<div class="form-group">
<label class="form-label">Video File *</label>
<label class="form-label"><i class="bi bi-camera-video" id="file-label-icon"></i> <span id="file-label-text">Video or Music File *</span></label>
<div id="dropzone">
<input type="file" name="video" id="video" accept="video/*" required>
<input type="file" name="video" id="video" accept="video/*,audio/mpeg,audio/mp4,audio/aac,audio/flac,audio/wav,.mp3,.m4a,.aac,.flac,.wav" required>
<div id="dropzone-default">
<div class="icon"><i class="bi bi-cloud-arrow-up"></i></div>
<p>Click to select or drag video here</p>
<p class="hint">MP4, MOV, AVI, WebM and more</p>
<p>Click to select or drag file here</p>
<p class="hint">Video: MP4, MOV, AVI, WebM — Audio: MP3, M4A, AAC, FLAC</p>
</div>
<div id="file-info">
<div class="file-icon"><i class="bi bi-film"></i></div>
<div class="file-icon"><i class="bi bi-film" id="file-preview-icon"></i></div>
<div class="file-details">
<p class="filename" id="filename"></p>
<p class="filesize" id="filesize"></p>
@@ -183,17 +177,48 @@
<textarea name="description" id="video-description" rows="4" class="form-textarea" placeholder="Tell viewers about your video (Markdown supported)"></textarea>
</div>
<!-- Thumbnail -->
<div class="form-group">
<label class="form-label">Thumbnail</label>
<!-- Thumbnail (video) -->
<div class="form-group" id="thumbnail-section">
<label class="form-label"><i class="bi bi-image"></i> <span id="thumbnail-label-text">Thumbnail</span></label>
<div id="thumbnail-dropzone">
<input type="file" name="thumbnail" id="thumbnail" accept="image/*">
<div id="thumbnail-default">
<i class="bi bi-card-image"></i>
<p>Click to select thumbnail (optional)</p>
<p id="thumbnail-dropzone-hint">Click to select thumbnail (optional, max 20MB)</p>
</div>
<div id="thumbnail-info">
<img id="thumbnail-preview-img" src="" alt="Thumbnail preview">
<div style="display:flex;align-items:center;gap:12px;padding:12px;background:#1f1f1f;border-radius:8px;border:1px solid #333;">
<div style="width:80px;height:48px;overflow:hidden;border-radius:6px;flex-shrink:0;">
<img id="thumbnail-preview-img" src="" alt="Thumbnail preview" style="width:100%;height:100%;object-fit:cover;">
</div>
<div style="flex:1;text-align:left;min-width:0;">
<p id="thumbnail-filename" style="color:#e5e5e5;font-weight:500;font-size:13px;margin:0 0 2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"></p>
<p id="thumbnail-filesize" style="color:#888;font-size:12px;margin:0;"></p>
</div>
<button type="button" class="btn-remove" onclick="removeThumbnail(event)" style="flex-shrink:0;"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</div>
</div>
<!-- Slides dropzone (audio only) -->
<div class="form-group" id="slides-section" style="display:none;">
<label class="form-label">
<i class="bi bi-images"></i> Slides <span style="color:var(--brand-red);">*</span>
<span style="font-weight:400;font-size:12px;color:var(--text-secondary);margin-left:6px;">Multiple images will crossfade while playing</span>
</label>
<div id="slides-dropzone" style="border:2px dashed var(--brand-red);border-radius:10px;padding:12px;cursor:pointer;text-align:center;min-height:72px;">
<input type="file" name="slides[]" id="slides-input" accept="image/*" multiple style="display:none;">
<div id="slides-default">
<i class="bi bi-images" style="font-size:28px;color:var(--text-secondary);"></i>
<p style="color:var(--text-secondary);font-size:13px;margin:4px 0 0;">Click or drag to add cover images</p>
</div>
<div id="slides-preview" style="display:none;">
<div id="slides-strip" style="display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:8px;"></div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:0 4px;">
<span id="slides-count" style="font-size:12px;color:var(--text-secondary);"></span>
<button type="button" onclick="removeSlides(event)" style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:12px;"><i class="bi bi-x-lg"></i> Clear</button>
</div>
</div>
</div>
</div>
@@ -256,7 +281,44 @@
<!-- Hidden Shorts - Auto-detected -->
<input type="hidden" name="is_shorts" id="is_shorts" value="0">
<!-- Download Access -->
<div class="form-group" style="margin-bottom: 16px;">
<label class="form-label">
<i class="bi bi-download"></i> Who can download?
</label>
<div class="options-grid" id="download-options">
<label class="option-item active" data-download="disabled">
<input type="radio" name="download_access" value="disabled" checked>
<div class="option-content">
<i class="bi bi-slash-circle"></i>
<span>Disabled</span>
</div>
</label>
<label class="option-item" data-download="everyone">
<input type="radio" name="download_access" value="everyone">
<div class="option-content">
<i class="bi bi-globe"></i>
<span>Everyone</span>
</div>
</label>
<label class="option-item" data-download="registered">
<input type="radio" name="download_access" value="registered">
<div class="option-content">
<i class="bi bi-person-check"></i>
<span>Members</span>
</div>
</label>
<label class="option-item" data-download="subscribers">
<input type="radio" name="download_access" value="subscribers">
<div class="option-content">
<i class="bi bi-star"></i>
<span>Subscribers</span>
</div>
</label>
</div>
</div>
<!-- Progress Bar -->
<div id="progress-container">
<div class="progress-bar-wrapper">
@@ -269,8 +331,8 @@
<div id="status-message"></div>
<!-- Submit Button -->
<button type="submit" id="submit-btn" class="btn-submit">
<i class="bi bi-cloud-arrow-up-fill"></i> Upload Video
<button type="submit" id="submit-btn" class="action-btn action-btn-primary btn-submit">
<i class="bi bi-cloud-arrow-up-fill"></i> <span>Upload Video</span>
</button>
</form>
</div>
@@ -279,142 +341,302 @@
@section('scripts')
<script>
// Video Dropzone
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('video');
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('video');
let thumbnailInput = document.getElementById('thumbnail');
const thumbnailDropzone = document.getElementById('thumbnail-dropzone');
const slidesDropzone = document.getElementById('slides-dropzone');
let slidesInput = document.getElementById('slides-input');
let currentFile = null;
fileInput.addEventListener('change', function() { handleFileSelect(this); });
dropzone.addEventListener('click', function(e) { if (e.target.classList.contains('btn-remove')) return; fileInput.click(); });
dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('dragover'); });
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover'));
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
dropzone.classList.remove('dragover');
if (e.dataTransfer.files.length) { fileInput.files = e.dataTransfer.files; handleFileSelect(fileInput); }
});
function handleFileSelect(input) {
if (input.files && input.files[0]) {
const file = input.files[0];
currentFile = file;
// Set title from filename
const fileNameWithoutExt = file.name.replace(/\.[^/.]+$/, '').replace(/[-_]/g, ' ');
document.getElementById('video-title').value = fileNameWithoutExt;
document.getElementById('filename').textContent = file.name;
document.getElementById('filesize').textContent = formatFileSize(file.size);
document.getElementById('dropzone-default').style.display = 'none';
document.getElementById('file-info').classList.add('active');
}
}
// ── Helpers ──────────────────────────────────────────────
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const k = 1024, sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
const AUDIO_EXTS = ['mp3', 'm4a', 'aac', 'flac', 'wav'];
function isAudioFile(file) {
const ext = file.name.split('.').pop().toLowerCase();
return AUDIO_EXTS.includes(ext) || file.type.startsWith('audio/');
}
function setAudioMode(audio) {
document.getElementById('file-label-icon').className = audio ? 'bi bi-music-note-beamed' : 'bi bi-camera-video';
document.getElementById('file-label-text').textContent = audio ? 'Music File *' : 'Video or Music File *';
// Toggle thumbnail vs slides section
document.getElementById('thumbnail-section').style.display = audio ? 'none' : '';
document.getElementById('slides-section').style.display = audio ? '' : 'none';
slidesDropzone.style.borderColor = audio ? 'var(--brand-red)' : '';
if (audio) {
document.querySelectorAll('#type-options .option-item').forEach(o => o.classList.remove('active'));
const musicOpt = document.querySelector('#type-options .option-item[data-type="music"]');
if (musicOpt) { musicOpt.classList.add('active'); musicOpt.querySelector('input').checked = true; }
}
document.getElementById('file-preview-icon').className = audio ? 'bi bi-music-note-beamed' : 'bi bi-film';
document.getElementById('submit-btn').innerHTML =
'<i class="bi bi-cloud-arrow-up-fill"></i> <span>' + (audio ? 'Upload Track' : 'Upload Video') + '</span>';
}
// ── Video/Audio File ─────────────────────────────────────
fileInput.addEventListener('change', function() { handleFileSelect(this); });
dropzone.addEventListener('click', function(e) {
if (e.target.closest('.btn-remove')) return;
fileInput.click();
});
dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('dragover'); });
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover'));
dropzone.addEventListener('drop', e => {
e.preventDefault(); dropzone.classList.remove('dragover');
if (e.dataTransfer.files.length) { fileInput.files = e.dataTransfer.files; handleFileSelect(fileInput); }
});
function handleFileSelect(input) {
if (!input.files || !input.files[0]) return;
const file = input.files[0];
const maxSize = 512 * 1024 * 1024;
const validVideoTypes = ['video/mp4','video/webm','video/ogg','video/quicktime','video/x-msvideo','video/x-flv','video/x-matroska'];
const validVideoExts = ['.mp4','.webm','.ogg','.mov','.avi','.wmv','.flv','.mkv'];
const ext = '.' + file.name.split('.').pop().toLowerCase();
const audio = isAudioFile(file);
const validVideo = validVideoTypes.includes(file.type) || validVideoExts.includes(ext);
if (!audio && !validVideo) {
showError('Invalid format. Accepted: MP4, MOV, AVI, WebM, MKV, MP3, M4A, AAC, FLAC, WAV.');
input.value = ''; return;
}
if (file.size > maxSize) {
showError('File size exceeds 512MB limit.');
input.value = ''; return;
}
currentFile = file;
setAudioMode(audio);
const titleWithoutExt = file.name.replace(/\.[^/.]+$/, '').replace(/[-_]/g, ' ');
document.getElementById('video-title').value = titleWithoutExt;
document.getElementById('filename').textContent = file.name;
document.getElementById('filesize').textContent = formatFileSize(file.size);
document.getElementById('dropzone-default').style.display = 'none';
document.getElementById('file-info').classList.add('active');
}
function removeVideo(e) {
e.preventDefault();
e.stopPropagation();
fileInput.value = '';
currentFile = null;
e.preventDefault(); e.stopPropagation();
fileInput.value = ''; currentFile = null;
document.getElementById('dropzone-default').style.display = 'block';
document.getElementById('file-info').classList.remove('active');
document.getElementById('video-title').value = '';
setAudioMode(false);
}
// Thumbnail
const thumbnailInput = document.getElementById('thumbnail');
const thumbnailDropzone = document.getElementById('thumbnail-dropzone');
thumbnailInput.addEventListener('change', function() {
if (this.files && this.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('thumbnail-preview-img').src = e.target.result;
};
reader.readAsDataURL(this.files[0]);
document.getElementById('thumbnail-default').style.display = 'none';
document.getElementById('thumbnail-info').classList.add('active');
// ── Thumbnail / Cover Art ─────────────────────────────────
let _thumbClickTs = 0;
thumbnailInput.addEventListener('change', function() { _thumbClickTs = 0; handleThumbnailSelect(this); });
thumbnailDropzone.addEventListener('click', function(e) {
if (e.target.closest('.btn-remove')) return;
const now = Date.now();
if (now - _thumbClickTs < 800) return;
_thumbClickTs = now;
if (typeof window.openCropperModal_thumb_create_mobile === 'function') {
window.openCropperModal_thumb_create_mobile();
} else {
thumbnailInput.click();
}
});
// Option handlers
thumbnailDropzone.addEventListener('dragover', e => { e.preventDefault(); thumbnailDropzone.classList.add('dragover'); });
thumbnailDropzone.addEventListener('dragleave', () => thumbnailDropzone.classList.remove('dragover'));
thumbnailDropzone.addEventListener('drop', e => {
e.preventDefault(); thumbnailDropzone.classList.remove('dragover');
if (e.dataTransfer.files.length) {
const droppedFile = e.dataTransfer.files[0];
if (typeof window.tcPreload_thumb_create_mobile === 'function') {
window.tcPreload_thumb_create_mobile(droppedFile);
window.openCropperModal_thumb_create_mobile();
} else {
thumbnailInput.files = e.dataTransfer.files;
handleThumbnailSelect(thumbnailInput);
}
}
});
function handleThumbnailSelect(input) {
if (!input.files || !input.files[0]) return;
const file = input.files[0];
document.getElementById('thumbnail-filename').textContent = file.name;
document.getElementById('thumbnail-filesize').textContent = formatFileSize(file.size);
const reader = new FileReader();
reader.onload = e => { document.getElementById('thumbnail-preview-img').src = e.target.result; };
reader.readAsDataURL(file);
document.getElementById('thumbnail-default').style.display = 'none';
document.getElementById('thumbnail-info').classList.add('active');
}
function removeThumbnail(e) {
e.preventDefault(); e.stopPropagation();
const fresh = thumbnailInput.cloneNode(false);
thumbnailInput.parentNode.replaceChild(fresh, thumbnailInput);
thumbnailInput = fresh;
thumbnailInput.addEventListener('change', function() { handleThumbnailSelect(this); });
document.getElementById('thumbnail-default').style.display = 'block';
document.getElementById('thumbnail-info').classList.remove('active');
}
// ── Slides dropzone (audio mode) ─────────────────────────
slidesDropzone.addEventListener('click', e => { if (e.target.closest('button')) return; slidesInput.click(); });
slidesInput.addEventListener('change', function() { handleSlidesSelect(this); });
slidesDropzone.addEventListener('dragover', e => { e.preventDefault(); slidesDropzone.classList.add('dragover'); });
slidesDropzone.addEventListener('dragleave', () => slidesDropzone.classList.remove('dragover'));
slidesDropzone.addEventListener('drop', e => {
e.preventDefault(); slidesDropzone.classList.remove('dragover');
if (e.dataTransfer.files.length) { slidesInput.files = e.dataTransfer.files; handleSlidesSelect(slidesInput); }
});
function handleSlidesSelect(input) {
if (!input.files || !input.files.length) return;
const files = Array.from(input.files);
const strip = document.getElementById('slides-strip');
strip.innerHTML = '';
files.forEach(f => {
const reader = new FileReader();
reader.onload = e => {
const img = document.createElement('img');
img.src = e.target.result;
img.style.cssText = 'width:56px;height:56px;object-fit:cover;border-radius:6px;border:1px solid rgba(255,255,255,.15);';
strip.appendChild(img);
};
reader.readAsDataURL(f);
});
document.getElementById('slides-count').textContent =
files.length === 1 ? '1 image — static cover' : files.length + ' images — will crossfade';
document.getElementById('slides-default').style.display = 'none';
document.getElementById('slides-preview').style.display = '';
slidesDropzone.style.borderColor = '#22c55e';
}
function removeSlides(e) {
e.preventDefault(); e.stopPropagation();
const fresh = slidesInput.cloneNode(false);
slidesInput.parentNode.replaceChild(fresh, slidesInput);
slidesInput = fresh;
slidesInput.addEventListener('change', function() { handleSlidesSelect(this); });
document.getElementById('slides-default').style.display = 'block';
document.getElementById('slides-preview').style.display = 'none';
slidesDropzone.style.borderColor = 'var(--brand-red)';
}
// ── Option selectors ─────────────────────────────────────
document.querySelectorAll('#type-options .option-item').forEach(item => {
item.querySelector('input').addEventListener('change', function() {
item.querySelector('input').addEventListener('change', () => {
document.querySelectorAll('#type-options .option-item').forEach(o => o.classList.remove('active'));
item.classList.add('active');
});
});
document.querySelectorAll('#visibility-options .option-item').forEach(item => {
item.querySelector('input').addEventListener('change', function() {
item.querySelector('input').addEventListener('change', () => {
document.querySelectorAll('#visibility-options .option-item').forEach(o => o.classList.remove('active'));
item.classList.add('active');
});
});
// Form submission
document.querySelectorAll('#download-options .option-item').forEach(item => {
item.querySelector('input').addEventListener('change', () => {
document.querySelectorAll('#download-options .option-item').forEach(o => o.classList.remove('active'));
item.classList.add('active');
});
});
// ── Form submission ───────────────────────────────────────
document.getElementById('upload-form').addEventListener('submit', function(e) {
e.preventDefault();
if (!currentFile) {
showError('Please select a video file');
return;
if (!currentFile) { showError('Please select a video or audio file.'); return; }
const title = document.getElementById('video-title').value.trim();
if (!title) { showError('Please enter a title.'); return; }
if (isAudioFile(currentFile)) {
if (!slidesInput.files || !slidesInput.files.length) {
showError('At least one cover image is required for audio tracks.');
slidesDropzone.style.borderColor = 'var(--brand-red)';
return;
}
}
const formData = new FormData(this);
const xhr = new XMLHttpRequest();
xhr.timeout = 1800000; // 30 min
document.getElementById('progress-container').classList.add('active');
document.getElementById('submit-btn').disabled = true;
document.getElementById('submit-btn').innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading...';
document.getElementById('status-message').className = '';
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
document.getElementById('progress-bar').style.width = percent + '%';
document.getElementById('progress-text').textContent = 'Uploading... ' + percent + '%';
const pct = Math.round((e.loaded / e.total) * 100);
document.getElementById('progress-bar').style.width = pct + '%';
document.getElementById('progress-text').textContent = 'Uploading... ' + pct + '%';
}
});
xhr.addEventListener('load', function() {
document.getElementById('progress-bar').style.width = '100%';
document.getElementById('progress-text').textContent = 'Processing...';
try {
const response = JSON.parse(xhr.responseText);
if (response.success) {
document.getElementById('progress-text').textContent = 'Done! Redirecting...';
window.location.href = response.redirect;
} else {
showError(response.message || 'Upload failed');
}
} catch(e) {
showError('Invalid response from server');
if (xhr.status >= 200 && xhr.status < 300) {
try {
const res = JSON.parse(xhr.responseText);
if (res.success) {
document.getElementById('progress-text').textContent = 'Done! Redirecting...';
window.location.href = res.redirect;
} else {
showError(res.message || 'Upload failed.');
}
} catch(err) { showError('Invalid response from server.'); }
} else {
try {
const res = JSON.parse(xhr.responseText);
if (res.errors) {
const msgs = Object.entries(res.errors).map(([f, errs]) => `<b>${f}:</b> ${errs.join(', ')}`).join('<br>');
showError(msgs);
} else {
showError(res.message || `Server error (${xhr.status})`);
}
} catch(err) { showError(`Upload failed (${xhr.status}).`); }
}
});
xhr.addEventListener('error', function() { showError('Upload failed. Please try again.'); });
xhr.addEventListener('error', () => showError('Upload failed. Check your connection.'));
xhr.addEventListener('timeout', () => showError('Upload timed out. The file may be too large.'));
xhr.open('POST', '{{ route("videos.store") }}');
xhr.setRequestHeader('X-CSRF-TOKEN', '{{ csrf_token() }}');
xhr.send(formData);
});
function showError(message) {
const status = document.getElementById('status-message');
status.innerHTML = '<i class="bi bi-exclamation-circle-fill"></i> ' + message;
status.className = 'error';
document.getElementById('submit-btn').disabled = false;
document.getElementById('submit-btn').innerHTML = '<i class="bi bi-cloud-arrow-up-fill"></i> Upload Video';
document.getElementById('submit-btn').innerHTML = '<i class="bi bi-cloud-arrow-up-fill"></i> <span>Upload Video</span>';
document.getElementById('progress-container').classList.remove('active');
}
</script>
<x-image-cropper
id="thumb_create_mobile"
:width="448"
:height="252"
shape="square"
target-input="thumbnail"
preview-img="thumbnail-preview-img"
output-width="1280"
title="Crop Thumbnail"
/>
@endsection
File diff suppressed because it is too large. Load diff
+196 -38
View File
@@ -1,62 +1,220 @@
@extends('layouts.app')
@section('title', isset($query) ? 'Search: ' . $query . ' | ' . config('app.name') : 'Video Gallery | ' . config('app.name'))
@section('title', isset($query) ? 'Search: ' . $query . ' | ' . config('app.name') : config('app.name'))
@section('extra_styles')
<style>
/* Search info */
.search-info {
margin-bottom: 20px;
padding: 16px;
background: var(--bg-secondary);
border-radius: 12px;
}
<style>
/* ── Regular video grid ── */
.yt-video-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
@media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: 1fr; gap: 14px; } }
.search-info h2 {
font-size: 20px;
margin: 0;
}
.search-info p {
color: var(--text-secondary);
margin: 8px 0 0;
}
/* ── Playlist count badge (right strip on thumbnail) ── */
.pl-count-badge {
position: absolute; inset: 0 0 0 auto;
width: 72px;
background: rgba(0,0,0,.78);
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 4px; font-size: 12px; font-weight: 600; color: #fff;
pointer-events: none;
}
.pl-count-badge i { font-size: 20px; }
/* Video Grid */
.yt-video-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
/* ── Playlist type badge (top-left on thumbnail) ── */
.feed-pl-type-badge {
position: absolute; top: 8px; left: 8px;
background: rgba(0,0,0,.75); color: #fff;
font-size: 11px; font-weight: 700;
padding: 3px 8px; border-radius: 4px;
display: flex; align-items: center; gap: 4px;
text-transform: uppercase; letter-spacing: .4px;
pointer-events: none;
}
@media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: 1fr; } }
/* ── Thumbnail orientation fix ── */
.yt-video-card .yt-video-thumb img { object-fit: cover; }
/* Other styles unchanged */
.yt-empty { text-align: center; padding: 80px 20px; }
</style>
/* ── Search result helpers ── */
.search-info { margin-bottom: 20px; padding: 16px; background: var(--bg-secondary); border-radius: 12px; }
.search-info h2 { font-size: 20px; margin: 0; }
.search-info p { color: var(--text-secondary); margin: 8px 0 0; }
.results-section-title {
font-size: 15px; font-weight: 600; color: var(--text-secondary);
text-transform: uppercase; letter-spacing: .05em;
margin: 28px 0 14px;
display: flex; align-items: center; gap: 8px;
}
.results-section-title::after { content: ''; flex: 1; height: 1px; background: var(--border-color); }
.yt-empty { text-align: center; padding: 80px 20px; }
</style>
@endsection
@section('content')
@isset($query)
<div class="search-info">
<h2>Search results for "{{ $query }}"</h2>
<p>{{ $videos->count() }} videos found</p>
</div>
@endisset
@if ($videos->isEmpty())
{{-- ── Filter chip bar ── --}}
@unless(isset($query))
@php $activeFilter = request('filter', 'all'); @endphp
<div class="yt-filter-bar">
<a href="{{ route('videos.index') }}"
class="yt-chip {{ $activeFilter === 'all' ? 'active' : '' }}">All</a>
<a href="{{ route('videos.trending') }}"
class="yt-chip {{ request()->is('trending') ? 'active' : '' }}">Trending</a>
<a href="{{ route('videos.index', ['filter' => 'music']) }}"
class="yt-chip {{ $activeFilter === 'music' ? 'active' : '' }}">Music</a>
<a href="{{ route('videos.index', ['filter' => 'match']) }}"
class="yt-chip {{ $activeFilter === 'match' ? 'active' : '' }}">Sports</a>
<a href="{{ route('videos.shorts') }}"
class="yt-chip {{ request()->is('shorts') ? 'active' : '' }}">Shorts</a>
<a href="{{ route('videos.index', ['filter' => 'playlists']) }}"
class="yt-chip {{ $activeFilter === 'playlists' ? 'active' : '' }}">Playlists</a>
<a href="{{ route('videos.index', ['filter' => 'latest']) }}"
class="yt-chip {{ $activeFilter === 'latest' ? 'active' : '' }}">New to You</a>
</div>
@endunless
{{-- ══════════════════════════════════════════════
SEARCH RESULTS
══════════════════════════════════════════════ --}}
@isset($query)
@php
$searchShorts = $shorts ?? collect();
$searchPlaylists = $playlists ?? collect();
$totalResults = $videos->count() + $searchShorts->count() + $searchPlaylists->count();
@endphp
<div class="search-info">
<h2>Search results for "{{ $query }}"</h2>
<p>
@if($videos->count()) {{ $videos->count() }} video{{ $videos->count() !== 1 ? 's' : '' }} @endif
@if($searchShorts->count()) &nbsp;·&nbsp; {{ $searchShorts->count() }} short{{ $searchShorts->count() !== 1 ? 's' : '' }} @endif
@if($searchPlaylists->count()) &nbsp;·&nbsp; {{ $searchPlaylists->count() }} playlist{{ $searchPlaylists->count() !== 1 ? 's' : '' }} @endif
@if($totalResults === 0) No results found @endif
</p>
</div>
@if($videos->count())
<div class="results-section-title"><i class="bi bi-play-circle"></i> Videos</div>
<div class="yt-video-grid">
@foreach($videos as $video)
@include('components.video-card', ['video' => $video])
@endforeach
</div>
@endif
@if($searchShorts->count())
<div class="results-section-title"><i class="bi bi-lightning-charge-fill"></i> Shorts</div>
<div class="yt-video-grid">
@foreach($searchShorts as $video)
@include('components.video-card', ['video' => $video])
@endforeach
</div>
@endif
@if($searchPlaylists->count())
<div class="results-section-title"><i class="bi bi-collection-play"></i> Playlists</div>
<div class="yt-video-grid">
@foreach($searchPlaylists as $pl)
@include('components.playlist-card', ['playlist' => $pl])
@endforeach
</div>
@endif
@if($totalResults === 0)
<div class="yt-empty">
<i class="bi bi-search" style="font-size:56px;color:var(--text-secondary);display:block;margin-bottom:12px;"></i>
<h2>No results for "{{ $query }}"</h2>
<p style="color:var(--text-secondary);">Try different keywords or browse the gallery.</p>
</div>
@endif
@endisset
{{-- ══════════════════════════════════════════════
PLAYLISTS-ONLY FILTER
══════════════════════════════════════════════ --}}
@if(!isset($query) && isset($filter) && $filter === 'playlists')
@if(isset($playlists) && $playlists->count())
<div class="yt-video-grid">
@foreach($playlists as $pl)
@include('components.playlist-card', ['playlist' => $pl])
@endforeach
</div>
@else
<div class="yt-empty">
<h2>No videos found</h2>
<i class="bi bi-collection-play" style="font-size:56px;color:var(--text-secondary);display:block;margin-bottom:12px;"></i>
<h2>No public playlists yet</h2>
</div>
@endif
{{-- ══════════════════════════════════════════════
HOME — MIXED FEED
══════════════════════════════════════════════ --}}
@elseif(!isset($query) && (!isset($filter) || $filter === 'all'))
@if(isset($feedItems) && $feedItems->count())
<div class="yt-video-grid">
@foreach($feedItems as $entry)
@if($entry['kind'] === 'video')
@include('components.video-card', ['video' => $entry['item']])
@else
@php $pl = $entry['item']; @endphp
@include('components.playlist-card', ['playlist' => $pl, 'showTypeBadge' => true])
@endif
@endforeach
</div>
@else
<div class="yt-empty">
<h2>No content yet</h2>
@auth
<a href="{{ route('videos.create') }}" class="btn btn-primary">Upload First Video</a>
<a href="{{ route('videos.create') }}" class="action-btn action-btn-primary" style="margin-top:12px;">Upload First Video</a>
@endauth
</div>
@endif
{{-- ══════════════════════════════════════════════
SINGLE-TYPE FILTERED VIEWS
══════════════════════════════════════════════ --}}
@elseif(!isset($query))
@if($videos->isEmpty())
<div class="yt-empty">
<i class="bi bi-camera-video" style="font-size:56px;color:var(--text-secondary);display:block;margin-bottom:12px;"></i>
<h2>Nothing here yet</h2>
</div>
@else
<div class="yt-video-grid">
@foreach ($videos as $video)
@foreach($videos as $video)
@include('components.video-card', ['video' => $video])
@endforeach
</div>
@endif
@endif
@endsection
@section('scripts')
<script>
(function () {
function adjustPlThumb(img) {
img.style.objectFit = img.naturalWidth < img.naturalHeight ? 'contain' : 'cover';
}
document.querySelectorAll('.yt-video-thumb img').forEach(function (img) {
if (img.complete && img.naturalWidth) {
adjustPlThumb(img);
} else {
img.addEventListener('load', function () { adjustPlThumb(img); });
}
});
})();
</script>
@endsection
@@ -0,0 +1,843 @@
@php
$audioUrl = route('videos.stream', $video);
$coverUrl = $video->thumbnail ? asset('storage/thumbnails/' . $video->thumbnail) : asset('storage/images/logo.png');
$nextUrl = isset($nextVideo, $playlist) ? route('videos.show', $nextVideo).'?playlist='.$playlist->share_token : null;
$prevUrl = isset($previousVideo, $playlist) ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null;
$slideUrls = $video->slides->count() > 1
? $video->slides->map(fn($s) => asset('storage/thumbnails/' . $s->filename))->values()->all()
: [];
@endphp
<div class="ytp-wrap" id="ytpWrap">
<div class="ytp audio-ytp" id="audioContainer" tabindex="0">
{{-- Cover art / slideshow --}}
@if(count($slideUrls) > 1)
<div class="slideshow-wrap" id="slideshowWrap">
<img src="{{ $slideUrls[0] }}" alt="" class="slide-img slide-a" id="slideA">
<img src="{{ $slideUrls[1] }}" alt="" class="slide-img slide-b" id="slideB">
</div>
@else
<img src="{{ $coverUrl }}" alt="{{ $video->title }}" class="audio-cover-img" id="audioCoverImg">
@endif
{{-- Bars canvas overlay --}}
<canvas id="audioAnimCanvas" style="position:absolute;inset:0;width:100%;height:100%;pointer-events:none;display:none;z-index:3;"></canvas>
{{-- Gradient --}}
<div class="ytp-gradient-bottom"></div>
{{-- Large play overlay --}}
<div class="ytp-large-play-btn" id="ytpLargePlay">
<i class="bi bi-play-fill"></i>
</div>
{{-- Controls --}}
<div class="ytp-chrome-bottom" id="ytpControls">
<div class="ytp-progress-bar-container" id="ytpProgressContainer">
<div class="ytp-progress-bar" id="ytpProgressBar">
<div class="ytp-play-progress" id="ytpPlayed"></div>
<div class="ytp-scrubber-container">
<div class="ytp-scrubber-button" id="ytpScrubber"></div>
</div>
<div class="ytp-hover-time" id="ytpHoverTime"></div>
</div>
</div>
<div class="ytp-chrome-controls">
<div class="ytp-left-controls">
<button class="ytp-button ytp-play-btn" id="ytpPlayBtn" title="Play (k)">
<svg class="ytp-svg-play" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
<svg class="ytp-svg-pause" viewBox="0 0 24 24" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
@if(isset($previousVideo) && $prevUrl)
<button class="ytp-button" title="Previous" onclick="window.location.href='{{ $prevUrl }}'">
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg>
</button>
@endif
@if(isset($nextVideo) && $nextUrl)
<button class="ytp-button" title="Next" onclick="window.location.href='{{ $nextUrl }}'">
<svg viewBox="0 0 24 24"><path d="m6 18 8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button>
@endif
<div class="ytp-volume-area">
<button class="ytp-button ytp-mute-btn" id="ytpMuteBtn" title="Mute (m)">
<svg class="ytp-svg-vol3" viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
<svg class="ytp-svg-vol0" viewBox="0 0 24 24" style="display:none"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4 9.91 6.09 12 8.18V4z"/></svg>
</button>
<div class="ytp-volume-slider-wrap">
<input type="range" class="ytp-volume-range" id="ytpVolume" min="0" max="100" step="1" value="50">
</div>
</div>
<div class="ytp-time-display">
<span id="ytpCurrent">0:00</span>
<span class="ytp-time-sep"> / </span>
<span id="ytpDuration">0:00</span>
</div>
</div>
<div class="ytp-right-controls">
<div class="ytp-settings-wrap" id="ytpSettingsWrap">
<button class="ytp-button ytp-settings-btn" id="ytpSettingsBtn" title="Settings">
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94s-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
</button>
<div class="ytp-settings-panel" id="ytpSettingsPanel">
<div class="ytp-settings-item" id="ytpSpeedRow">
<svg viewBox="0 0 24 24"><path d="M10 8v8l6-4-6-4zm6.5 4A6.5 6.5 0 1 1 9 6.04V4.02A8.5 8.5 0 1 0 18.5 12H16.5z"/></svg>
<span>Playback speed</span>
<span class="ytp-settings-val" id="ytpSpeedLabel">Normal</span>
<svg class="ytp-chevron" viewBox="0 0 24 24"><path d="M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</div>
<div class="ytp-speed-panel" id="ytpSpeedPanel">
<div class="ytp-speed-back" id="ytpSpeedBack">
<svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
Playback speed
</div>
@foreach([['0.25','0.25'],['0.5','0.5'],['0.75','0.75'],['1','Normal'],['1.25','1.25'],['1.5','1.5'],['1.75','1.75'],['2','2']] as [$val,$label])
<div class="ytp-speed-option {{ $val === '1' ? 'active' : '' }}" data-speed="{{ $val }}">
<svg class="ytp-speed-check" viewBox="0 0 24 24"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
{{ $label }}
</div>
@endforeach
</div>
{{-- Loop toggle --}}
<div class="ytp-settings-item ytp-toggle-row" id="ytpLoopRow">
<svg viewBox="0 0 24 24"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
<span>Loop</span>
<span class="ytp-settings-val ytp-toggle-val" id="ytpLoopVal">Off</span>
<div class="ytp-toggle-switch"><div class="ytp-toggle-thumb"></div></div>
</div>
</div>
</div>
{{-- Bars visualiser toggle --}}
<button class="ytp-button audio-bars-btn" id="ytpBarsBtn" title="Visualiser: Off">
<svg viewBox="0 0 24 24">
<rect x="2" y="14" width="4" height="8" rx="1" fill="white"/>
<rect x="8" y="8" width="4" height="14" rx="1" fill="white"/>
<rect x="14" y="11" width="4" height="11" rx="1" fill="white"/>
<rect x="20" y="5" width="2" height="17" rx="1" fill="white"/>
</svg>
</button>
<button class="ytp-button ytp-fs-btn" id="ytpFsBtn" title="Full screen (f)">
<svg class="ytp-svg-fs-enter" viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>
<svg class="ytp-svg-fs-exit" viewBox="0 0 24 24" style="display:none"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>
</button>
</div>
</div>
</div>
</div>
</div>
{{-- Hidden audio element --}}
<audio id="audioEl" src="{{ $audioUrl }}" preload="metadata"></audio>
{{-- ══ CSS ══ --}}
<style>
.audio-ytp { cursor: default; }
.audio-cover-img {
position: absolute;
inset: 0; width: 100%; height: 100%;
object-fit: cover; display: block;
}
/* Slideshow */
.slideshow-wrap { position: absolute; inset: 0; background: #000; }
.slide-img {
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; display: block;
transition: opacity 1s ease-in-out;
}
.slide-img.portrait { object-fit: contain; }
.slide-a { opacity: 1; z-index: 1; }
.slide-b { opacity: 0; z-index: 0; }
.audio-ytp .ytp-gradient-bottom,
.audio-ytp .ytp-chrome-bottom,
.audio-ytp .ytp-large-play-btn { z-index: 4; }
/* Bars button: dim when off, red when on */
.audio-bars-btn svg { opacity: .45; transition: opacity .15s; }
.audio-bars-btn.bars-on svg { opacity: 1; fill: #f00 !important; }
.audio-bars-btn.bars-on rect { fill: #f00 !important; }
/* ══ Full ytp styles ══ */
.ytp-wrap {
position: relative; width: 100%; background: #000;
border-radius: 12px; overflow: hidden;
aspect-ratio: 16/9; max-height: 70vh;
}
.ytp {
position: relative; width: 100%; height: 100%;
background: #000; outline: none;
user-select: none; overflow: hidden;
font-family: Roboto, Arial, sans-serif;
}
.ytp:focus { outline: none; }
.ytp-gradient-bottom {
position: absolute; bottom: 0; left: 0; right: 0;
height: 98px;
background: linear-gradient(rgba(0,0,0,0), rgba(0,0,0,.75));
pointer-events: none; transition: opacity .25s;
}
.ytp-large-play-btn {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
pointer-events: none; opacity: 0; transition: opacity .2s;
}
.ytp-large-play-btn i { font-size: 72px; color: rgba(255,255,255,.9); text-shadow: 0 0 30px rgba(0,0,0,.6); }
.ytp-large-play-btn.visible { opacity: 1; }
.ytp-chrome-bottom {
position: absolute; bottom: 0; left: 0; right: 0;
padding: 0 12px 8px;
transition: opacity .25s, transform .25s;
}
.ytp.controls-hidden .ytp-chrome-bottom { opacity: 0; transform: translateY(4px); pointer-events: none; }
.ytp.controls-hidden .ytp-gradient-bottom { opacity: 0; }
.ytp-progress-bar-container { padding: 4px 0; cursor: pointer; margin-bottom: 4px; }
.ytp-progress-bar {
position: relative; height: 3px;
background: rgba(255,255,255,.2); border-radius: 2px; transition: height .1s;
}
.ytp-progress-bar-container:hover .ytp-progress-bar,
.ytp-progress-bar.dragging { height: 5px; }
.ytp-play-progress {
position: absolute; top: 0; left: 0; bottom: 0;
background: #f00; border-radius: 2px; width: 0; pointer-events: none;
}
.ytp-scrubber-container {
position: absolute; top: 50%; transform: translateY(-50%);
width: 0; pointer-events: none;
}
.ytp-scrubber-button {
width: 13px; height: 13px; border-radius: 50%; background: #f00;
transform: translate(-50%, 0) scale(0); transition: transform .1s; margin-top: -4px;
}
.ytp-progress-bar-container:hover .ytp-scrubber-button,
.ytp-progress-bar.dragging .ytp-scrubber-button { transform: translate(-50%, 0) scale(1); }
.ytp-hover-time {
position: absolute; bottom: 16px;
background: rgba(28,28,28,.9); color: #fff;
font-size: 12px; padding: 3px 6px; border-radius: 4px;
pointer-events: none; opacity: 0;
transform: translateX(-50%); white-space: nowrap; transition: opacity .1s;
}
.ytp-progress-bar-container:hover .ytp-hover-time { opacity: 1; }
.ytp-chrome-controls {
display: flex; align-items: center; justify-content: space-between; height: 36px;
}
.ytp-left-controls, .ytp-right-controls { display: flex; align-items: center; gap: 4px; }
.ytp-button {
background: none; border: none; color: #fff; cursor: pointer;
padding: 0; width: 36px; height: 36px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 50%; transition: background .15s; flex-shrink: 0;
}
.ytp-button:hover { background: rgba(255,255,255,.1); }
.ytp-button svg { width: 22px; height: 22px; fill: #fff; pointer-events: none; }
.ytp-button:focus { outline: none; }
.ytp-play-btn svg { width: 26px; height: 26px; }
.ytp-volume-area { display: flex; align-items: center; }
.ytp-volume-slider-wrap {
overflow: hidden; width: 0; transition: width .2s;
display: flex; align-items: center;
}
.ytp-volume-area:hover .ytp-volume-slider-wrap,
.ytp-volume-area:focus-within .ytp-volume-slider-wrap { width: 60px; }
.ytp-volume-range {
-webkit-appearance: none; appearance: none;
width: 52px; height: 3px; border-radius: 2px;
background: linear-gradient(to right, #fff var(--vol,50%), rgba(255,255,255,.3) var(--vol,50%));
outline: none; cursor: pointer; margin: 0 4px;
}
.ytp-volume-range::-webkit-slider-thumb { -webkit-appearance: none; width: 13px; height: 13px; border-radius: 50%; background: #fff; cursor: pointer; }
.ytp-volume-range::-moz-range-thumb { width: 13px; height: 13px; border-radius: 50%; background: #fff; border: none; cursor: pointer; }
.ytp-time-display { font-size: 13px; color: #fff; white-space: nowrap; padding: 0 6px; line-height: 36px; }
.ytp-time-sep { opacity: .6; margin: 0 2px; }
.ytp-settings-wrap { position: relative; }
.ytp-settings-panel {
display: none; position: absolute; bottom: 44px; right: 0;
background: rgba(28,28,28,.95); border-radius: 12px;
min-width: 200px; overflow: hidden;
box-shadow: 0 4px 24px rgba(0,0,0,.6); z-index: 100;
}
.ytp-settings-panel.open { display: block; }
.ytp-settings-item {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; color: #fff; font-size: 13px;
cursor: pointer; transition: background .15s; white-space: nowrap;
}
.ytp-settings-item:hover { background: rgba(255,255,255,.1); }
.ytp-settings-item svg { width: 20px; height: 20px; fill: #fff; flex-shrink: 0; }
.ytp-settings-item .ytp-settings-val { margin-left: auto; color: rgba(255,255,255,.7); font-size: 12px; margin-right: 4px; }
.ytp-chevron { width: 18px; height: 18px; flex-shrink: 0; }
.ytp-speed-panel { display: none; }
.ytp-speed-panel.open { display: block; }
.ytp-speed-back {
display: flex; align-items: center; gap: 12px;
padding: 12px 16px; color: #fff; font-size: 13px; font-weight: 600;
cursor: pointer; border-bottom: 1px solid rgba(255,255,255,.15);
}
.ytp-speed-back:hover { background: rgba(255,255,255,.1); }
.ytp-speed-back svg { width: 20px; height: 20px; fill: #fff; }
.ytp-speed-option {
display: flex; align-items: center; gap: 12px;
padding: 10px 16px; color: #fff; font-size: 13px;
cursor: pointer; transition: background .15s;
}
.ytp-speed-option:hover { background: rgba(255,255,255,.1); }
.ytp-speed-check { width: 18px; height: 18px; fill: #fff; opacity: 0; flex-shrink: 0; }
.ytp-speed-option.active .ytp-speed-check { opacity: 1; }
.ytp-wrap.ytp-fullscreen {
position: fixed !important; inset: 0; z-index: 99999;
max-height: 100vh; height: 100vh; width: 100vw;
border-radius: 0; aspect-ratio: unset; margin: 0 !important;
}
.ytp-toggle-row { border-top: 1px solid rgba(255,255,255,.08); }
.ytp-toggle-row svg { opacity: .75; }
.ytp-toggle-val { font-size: 11px !important; opacity: .6; transition: color .15s, opacity .15s; }
.ytp-toggle-row.is-on .ytp-toggle-val { color: #f00; opacity: 1; }
.ytp-toggle-row.is-on svg { fill: #f00; opacity: 1; }
.ytp-toggle-switch {
width: 28px; height: 16px; background: rgba(255,255,255,.2);
border-radius: 8px; position: relative; flex-shrink: 0;
transition: background .2s; margin-left: 6px;
}
.ytp-toggle-row.is-on .ytp-toggle-switch { background: #e61e1e; }
.ytp-toggle-thumb {
position: absolute; top: 2px; left: 2px;
width: 12px; height: 12px; background: #fff;
border-radius: 50%; transition: transform .2s;
box-shadow: 0 1px 3px rgba(0,0,0,.4);
}
.ytp-toggle-row.is-on .ytp-toggle-thumb { transform: translateX(12px); }
@media (max-width: 576px) {
.ytp-wrap { border-radius: 0; max-height: 56vw; }
.ytp-button { width: 32px; height: 32px; }
.ytp-button svg { width: 18px; height: 18px; }
.ytp-time-display { font-size: 11px; padding: 0 4px; }
}
</style>
{{-- ══ JS ══ --}}
<script>
(function () {
const wrap = document.getElementById('ytpWrap');
const player = document.getElementById('audioContainer');
const audio = document.getElementById('audioEl');
const playBtn = document.getElementById('ytpPlayBtn');
const muteBtn = document.getElementById('ytpMuteBtn');
const volRange = document.getElementById('ytpVolume');
const timeCur = document.getElementById('ytpCurrent');
const timeDur = document.getElementById('ytpDuration');
const progCont = document.getElementById('ytpProgressContainer');
const progBar = document.getElementById('ytpProgressBar');
const played = document.getElementById('ytpPlayed');
const scrubber = document.getElementById('ytpScrubber');
const hoverTime = document.getElementById('ytpHoverTime');
const largePlay = document.getElementById('ytpLargePlay');
const settingsBtn = document.getElementById('ytpSettingsBtn');
const settingsPanel = document.getElementById('ytpSettingsPanel');
const speedRow = document.getElementById('ytpSpeedRow');
const speedPanel = document.getElementById('ytpSpeedPanel');
const speedBack = document.getElementById('ytpSpeedBack');
const speedLabel = document.getElementById('ytpSpeedLabel');
const speedOpts = document.querySelectorAll('.ytp-speed-option');
const fsBtn = document.getElementById('ytpFsBtn');
const loopRow = document.getElementById('ytpLoopRow');
const loopVal = document.getElementById('ytpLoopVal');
const barsBtn = document.getElementById('ytpBarsBtn');
const animCanvas = document.getElementById('audioAnimCanvas');
const NEXT_URL = @json($nextUrl ?? null);
let hideTimer = null;
let isDragging = false;
// ── Helpers ──────────────────────────────────────────────────
function fmt(s) {
if (!isFinite(s) || isNaN(s)) return '0:00';
s = Math.floor(s);
const m = Math.floor(s / 60), sec = s % 60;
return m + ':' + String(sec).padStart(2, '0');
}
function updatePlayIcon() {
playBtn.querySelector('.ytp-svg-play').style.display = audio.paused ? '' : 'none';
playBtn.querySelector('.ytp-svg-pause').style.display = audio.paused ? 'none' : '';
player.classList.toggle('playing', !audio.paused);
}
function updateVolumeIcon() {
const muted = audio.muted || audio.volume === 0;
muteBtn.querySelector('.ytp-svg-vol3').style.display = muted ? 'none' : '';
muteBtn.querySelector('.ytp-svg-vol0').style.display = muted ? '' : 'none';
const v = muted ? 0 : Math.round(audio.volume * 100);
volRange.value = v;
volRange.style.setProperty('--vol', v + '%');
}
function updateProgress() {
if (!audio.duration) return;
const pct = (audio.currentTime / audio.duration) * 100;
played.style.width = pct + '%';
scrubber.parentElement.style.left = pct + '%';
timeCur.textContent = fmt(audio.currentTime);
}
// ── Controls visibility ──────────────────────────────────────
function showControls() {
player.classList.remove('controls-hidden');
clearTimeout(hideTimer);
if (!audio.paused) hideTimer = setTimeout(() => player.classList.add('controls-hidden'), 3000);
}
// ── Play / Pause ─────────────────────────────────────────────
function togglePlay() {
if (audio.paused) { audio.play(); } else { audio.pause(); }
}
playBtn.addEventListener('click', e => { e.stopPropagation(); togglePlay(); showControls(); });
player.addEventListener('click', e => {
if (settingsPanel.contains(e.target) || settingsBtn === e.target) return;
togglePlay();
});
player.addEventListener('mousemove', showControls);
// ── Volume ───────────────────────────────────────────────────
muteBtn.addEventListener('click', e => {
e.stopPropagation();
audio.muted = !audio.muted;
if (!audio.muted && audio.volume === 0) audio.volume = 0.5;
updateVolumeIcon();
localStorage.setItem('ytpMuted', audio.muted ? '1' : '0');
});
volRange.addEventListener('input', e => {
e.stopPropagation();
const v = parseInt(e.target.value) / 100;
audio.volume = v;
audio.muted = v === 0;
updateVolumeIcon();
localStorage.setItem('ytpVolume', e.target.value);
});
volRange.addEventListener('click', e => e.stopPropagation());
// ── Progress bar ─────────────────────────────────────────────
function seekTo(pct) {
if (!audio.duration) return;
audio.currentTime = Math.max(0, Math.min(1, pct)) * audio.duration;
updateProgress();
}
function progressPct(clientX) {
const rect = progBar.getBoundingClientRect();
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
}
progCont.addEventListener('mousemove', e => {
hoverTime.textContent = fmt(progressPct(e.clientX) * (audio.duration || 0));
hoverTime.style.left = (progressPct(e.clientX) * 100) + '%';
if (isDragging) seekTo(progressPct(e.clientX));
});
progCont.addEventListener('mousedown', e => {
e.preventDefault(); isDragging = true;
progBar.classList.add('dragging'); seekTo(progressPct(e.clientX));
});
document.addEventListener('mousemove', e => { if (isDragging) seekTo(progressPct(e.clientX)); });
document.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; progBar.classList.remove('dragging'); } });
progCont.addEventListener('touchstart', e => { isDragging = true; progBar.classList.add('dragging'); seekTo(progressPct(e.touches[0].clientX)); }, { passive: true });
progCont.addEventListener('touchmove', e => { if (isDragging) seekTo(progressPct(e.touches[0].clientX)); }, { passive: true });
progCont.addEventListener('touchend', () => { isDragging = false; progBar.classList.remove('dragging'); });
// ── Settings / Speed ─────────────────────────────────────────
settingsBtn.addEventListener('click', e => {
e.stopPropagation();
const open = settingsPanel.classList.toggle('open');
if (!open) { speedPanel.classList.remove('open'); speedRow.style.display = ''; }
clearTimeout(hideTimer);
});
document.addEventListener('click', e => {
if (!document.getElementById('ytpSettingsWrap').contains(e.target)) {
settingsPanel.classList.remove('open');
speedPanel.classList.remove('open');
speedRow.style.display = '';
}
});
speedRow.addEventListener('click', e => { e.stopPropagation(); speedRow.style.display = 'none'; speedPanel.classList.add('open'); });
speedBack.addEventListener('click', e => { e.stopPropagation(); speedPanel.classList.remove('open'); speedRow.style.display = ''; });
speedOpts.forEach(opt => {
opt.addEventListener('click', e => {
e.stopPropagation();
const s = parseFloat(opt.dataset.speed);
audio.playbackRate = s;
speedOpts.forEach(o => o.classList.remove('active'));
opt.classList.add('active');
speedLabel.textContent = s === 1 ? 'Normal' : s;
settingsPanel.classList.remove('open');
speedPanel.classList.remove('open');
speedRow.style.display = '';
});
});
// ── Fullscreen ───────────────────────────────────────────────
fsBtn.addEventListener('click', e => {
e.stopPropagation();
document.fullscreenElement ? document.exitFullscreen() : wrap.requestFullscreen();
showControls();
});
document.addEventListener('fullscreenchange', () => {
const fs = !!document.fullscreenElement;
wrap.classList.toggle('ytp-fullscreen', fs);
fsBtn.querySelector('.ytp-svg-fs-enter').style.display = fs ? 'none' : '';
fsBtn.querySelector('.ytp-svg-fs-exit').style.display = fs ? '' : 'none';
});
// ── Keyboard shortcuts ────────────────────────────────────────
document.addEventListener('keydown', e => {
const tag = document.activeElement.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement.isContentEditable) return;
switch (e.key) {
case ' ': case 'k': case 'K': e.preventDefault(); togglePlay(); showControls(); break;
case 'm': case 'M':
e.preventDefault();
audio.muted = !audio.muted;
if (!audio.muted && audio.volume === 0) audio.volume = 0.5;
updateVolumeIcon(); showControls(); break;
case 'f': case 'F': e.preventDefault(); fsBtn.click(); break;
case 'ArrowLeft': e.preventDefault(); if (audio.duration) audio.currentTime = Math.max(0, audio.currentTime - 5); showControls(); break;
case 'ArrowRight': e.preventDefault(); if (audio.duration) audio.currentTime = Math.min(audio.duration, audio.currentTime + 5); showControls(); break;
case 'ArrowUp': e.preventDefault(); audio.volume = Math.min(1, audio.volume + 0.05); audio.muted = false; updateVolumeIcon(); showControls(); break;
case 'ArrowDown': e.preventDefault(); audio.volume = Math.max(0, audio.volume - 0.05); updateVolumeIcon(); showControls(); break;
default:
if (e.key >= '0' && e.key <= '9') {
e.preventDefault();
if (audio.duration) audio.currentTime = (parseInt(e.key) / 10) * audio.duration;
showControls();
}
}
});
// ── Audio events ─────────────────────────────────────────────
audio.addEventListener('play', () => { updatePlayIcon(); startBars(); showControls(); largePlay.classList.remove('visible'); requestWakeLock(); });
audio.addEventListener('pause', () => { updatePlayIcon(); stopBars(); showControls(); largePlay.classList.add('visible'); clearTimeout(hideTimer); releaseWakeLock(); });
audio.addEventListener('timeupdate', updateProgress);
audio.addEventListener('durationchange', () => { timeDur.textContent = fmt(audio.duration); });
audio.addEventListener('ended', () => { releaseWakeLock(); if (NEXT_URL) window.location.href = NEXT_URL; });
audio.addEventListener('volumechange', updateVolumeIcon);
// ── Loop ─────────────────────────────────────────────────────
let isLooping = localStorage.getItem('ytpLoop') === '1';
audio.loop = isLooping;
function applyLoopState() {
if (!loopRow) return;
loopRow.classList.toggle('is-on', isLooping);
if (loopVal) loopVal.textContent = isLooping ? 'On' : 'Off';
}
applyLoopState();
if (loopRow) {
loopRow.addEventListener('click', e => {
e.stopPropagation();
isLooping = !isLooping;
audio.loop = isLooping;
localStorage.setItem('ytpLoop', isLooping ? '1' : '0');
applyLoopState();
});
}
// ── Wake Lock ─────────────────────────────────────────────────
let wakeLock = null;
async function requestWakeLock() {
if (!('wakeLock' in navigator) || wakeLock) return;
try { wakeLock = await navigator.wakeLock.request('screen'); } catch(e) {}
}
function releaseWakeLock() {
if (wakeLock) { wakeLock.release(); wakeLock = null; }
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && !audio.paused) requestWakeLock();
});
// ── Bars visualiser ───────────────────────────────────────────
let audioCtx = null, analyser = null, dataArray = null, rafId = null;
let barsOn = localStorage.getItem('audioBarsOn') === '1';
// Extract dominant colors from cover art for bar gradients
let imgColors = ['rgb(255,255,255)', 'rgb(200,200,200)', 'rgb(170,170,170)'];
function colorWithAlpha(col, a) {
return col.replace('rgb(', 'rgba(').replace(')', ',' + a + ')');
}
function extractColors(img) {
try {
const cv = document.createElement('canvas');
cv.width = cv.height = 24;
const cx = cv.getContext('2d');
cx.drawImage(img, 0, 0, 24, 24);
const px = cx.getImageData(0, 0, 24, 24).data;
const buckets = {};
for (let i = 0; i < px.length; i += 4) {
const r = px[i], g = px[i+1], b = px[i+2];
const bright = (r + g + b) / 3;
if (bright < 25 || bright > 230) continue;
const max = Math.max(r,g,b), min = Math.min(r,g,b);
if (max === 0 || (max - min) / max < 0.25) continue;
const key = `${r>>2},${g>>2},${b>>2}`;
buckets[key] = (buckets[key] || { r:0, g:0, b:0, n:0 });
buckets[key].r += r; buckets[key].g += g; buckets[key].b += b; buckets[key].n++;
}
const sorted = Object.values(buckets).sort((a,b) => b.n - a.n);
if (!sorted.length) return;
const chosen = [sorted[0]];
for (let i = 1; i < sorted.length && chosen.length < 3; i++) {
const c = sorted[i];
const far = chosen.every(e => {
const dr = e.r/e.n - c.r/c.n, dg = e.g/e.n - c.g/c.n, db = e.b/e.n - c.b/c.n;
return Math.sqrt(dr*dr + dg*dg + db*db) > 60;
});
if (far) chosen.push(c);
}
imgColors = chosen.map(c => `rgb(${Math.round(c.r/c.n)},${Math.round(c.g/c.n)},${Math.round(c.b/c.n)})`);
while (imgColors.length < 3) imgColors.push(imgColors[0]);
} catch(e) {}
}
const coverImg = document.getElementById('audioCoverImg');
if (coverImg) {
if (coverImg.complete && coverImg.naturalWidth) {
extractColors(coverImg);
if (coverImg.naturalHeight > coverImg.naturalWidth) coverImg.style.objectFit = 'contain';
} else {
coverImg.addEventListener('load', () => {
extractColors(coverImg);
if (coverImg.naturalHeight > coverImg.naturalWidth) coverImg.style.objectFit = 'contain';
}, { once: true });
}
}
function applyBarsState() {
barsBtn.classList.toggle('bars-on', barsOn);
barsBtn.title = barsOn ? 'Visualiser: On' : 'Visualiser: Off';
animCanvas.style.display = barsOn ? 'block' : 'none';
}
applyBarsState();
barsBtn.addEventListener('click', e => {
e.stopPropagation();
barsOn = !barsOn;
localStorage.setItem('audioBarsOn', barsOn ? '1' : '0');
applyBarsState();
if (barsOn && !audio.paused) startBars(); else stopBars();
showControls();
});
function initAnalyser() {
if (audioCtx) { audioCtx.resume(); return; }
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const src = audioCtx.createMediaElementSource(audio);
analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.75;
src.connect(analyser);
analyser.connect(audioCtx.destination);
dataArray = new Uint8Array(analyser.frequencyBinCount);
} catch (e) { console.warn('Web Audio unavailable:', e); }
}
function drawBars() {
if (!analyser) return;
analyser.getByteFrequencyData(dataArray);
const ctx = animCanvas.getContext('2d');
const w = animCanvas.offsetWidth;
const h = animCanvas.offsetHeight;
if (animCanvas.width !== w) animCanvas.width = w;
if (animCanvas.height !== h) animCanvas.height = h;
ctx.clearRect(0, 0, w, h);
const bins = 48;
const barW = (w / bins) * 0.7;
const gap = (w / bins) * 0.3;
const maxH = h * 0.25;
for (let i = 0; i < bins; i++) {
const val = dataArray[i + 2] / 255;
const barH = Math.max(3, val * maxH);
const x = i * (barW + gap) + gap / 2;
const y = h - barH;
const col = imgColors[Math.floor((i / bins) * imgColors.length)];
const grad = ctx.createLinearGradient(0, y, 0, h);
grad.addColorStop(0, colorWithAlpha(col, (0.5 + val * 0.5).toFixed(2)));
grad.addColorStop(1, colorWithAlpha(col, 0.12));
ctx.fillStyle = grad;
ctx.beginPath();
ctx.roundRect(x, y, barW, barH, [3, 3, 0, 0]);
ctx.fill();
}
}
function barsLoop() {
rafId = requestAnimationFrame(barsLoop);
drawBars();
}
function startBars() {
if (!barsOn) return;
initAnalyser();
if (!rafId) rafId = requestAnimationFrame(barsLoop);
}
function stopBars() {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
if (animCanvas.getContext) animCanvas.getContext('2d').clearRect(0, 0, animCanvas.width, animCanvas.height);
}
// ── Crossfade slideshow ───────────────────────────────────────
const SLIDE_URLS = @json($slideUrls);
if (SLIDE_URLS.length > 1) {
const slideA = document.getElementById('slideA');
const slideB = document.getElementById('slideB');
let currentSlide = 0;
let aIsTop = true; // slideA starts on top (opacity 1)
// Preload all images and detect portrait orientation
const slideOrientations = new Array(SLIDE_URLS.length).fill(false); // true = portrait
SLIDE_URLS.forEach((url, idx) => {
const img = new Image();
img.onload = () => { slideOrientations[idx] = img.naturalHeight > img.naturalWidth; };
img.src = url;
});
function applyOrientation(el, idx) {
el.classList.toggle('portrait', !!slideOrientations[idx]);
}
function getSlideInterval() {
return audio.duration ? (audio.duration / SLIDE_URLS.length) * 1000 : 8000;
}
function advanceSlide() {
currentSlide = (currentSlide + 1) % SLIDE_URLS.length;
const next = SLIDE_URLS[currentSlide];
if (aIsTop) {
slideB.src = next;
applyOrientation(slideB, currentSlide);
slideB.style.zIndex = '2';
slideB.style.opacity = '1';
slideA.style.opacity = '0';
slideA.style.zIndex = '1';
} else {
slideA.src = next;
applyOrientation(slideA, currentSlide);
slideA.style.zIndex = '2';
slideA.style.opacity = '1';
slideB.style.opacity = '0';
slideB.style.zIndex = '1';
}
aIsTop = !aIsTop;
}
let slideshowTimer = null;
function startSlideshow() {
if (slideshowTimer || SLIDE_URLS.length <= 1) return;
slideshowTimer = setInterval(advanceSlide, getSlideInterval());
}
function stopSlideshow() {
clearInterval(slideshowTimer);
slideshowTimer = null;
}
audio.addEventListener('play', startSlideshow);
audio.addEventListener('pause', stopSlideshow);
audio.addEventListener('ended', stopSlideshow);
// Seek: jump to the correct slide instantly (no transition flash)
audio.addEventListener('seeked', () => {
if (!audio.duration) return;
stopSlideshow();
const idx = Math.min(
Math.floor((audio.currentTime / audio.duration) * SLIDE_URLS.length),
SLIDE_URLS.length - 1
);
currentSlide = idx;
// Reset: show correct slide without animation
slideA.style.transition = 'none';
slideB.style.transition = 'none';
slideA.src = SLIDE_URLS[idx];
applyOrientation(slideA, idx);
slideA.style.opacity = '1'; slideA.style.zIndex = '2';
slideB.style.opacity = '0'; slideB.style.zIndex = '1';
const nextIdx = (idx + 1) % SLIDE_URLS.length;
slideB.src = SLIDE_URLS[nextIdx];
applyOrientation(slideB, nextIdx);
aIsTop = true;
requestAnimationFrame(() => {
slideA.style.transition = '';
slideB.style.transition = '';
});
if (!audio.paused) startSlideshow();
});
// Extract colors + apply orientation for first slide
function initSlideA() {
extractColors(slideA);
const portrait = slideA.naturalHeight > slideA.naturalWidth;
slideA.classList.toggle('portrait', portrait);
slideOrientations[0] = portrait;
}
if (slideA.complete && slideA.naturalWidth) {
initSlideA();
} else {
slideA.addEventListener('load', initSlideA, { once: true });
}
}
// ── Init ─────────────────────────────────────────────────────
const savedVol = localStorage.getItem('ytpVolume');
const savedMuted = localStorage.getItem('ytpMuted');
audio.volume = savedVol ? parseInt(savedVol) / 100 : 0.8;
audio.muted = true; // start muted so autoplay always works
updateVolumeIcon();
largePlay.classList.add('visible');
showControls();
// Once playing, restore user's mute preference
audio.addEventListener('playing', function restoreSound() {
audio.removeEventListener('playing', restoreSound);
if (savedMuted !== '1') {
audio.muted = false;
updateVolumeIcon();
}
}, { once: true });
audio.addEventListener('loadedmetadata', () => {
timeDur.textContent = fmt(audio.duration);
const p = audio.play();
if (p) p.catch(() => {
audio.muted = true;
audio.play().catch(() => {});
});
});
})();
</script>
@@ -58,7 +58,7 @@
rows="2" style="margin-bottom: 8px;"></textarea>
<div style="display: flex; gap: 8px; justify-content: flex-end;">
<button type="button" class="_comment-btn _comment-btn-primary"
onclick="_comment.submitReply({{ $video->id ?? $comment->video_id }}, {{ $comment->id }})">
onclick="_comment.submitReply('{{ isset($video) ? $video->getRouteKey() : \App\Models\Video::encodeId($comment->video_id) }}', {{ $comment->id }})">
<i class="bi bi-chat-dots"></i>
<span>Send</span>
</button>
@@ -0,0 +1,85 @@
{{--
Tabbed description + insights box.
Expects: $video (Video model)
Optionally accepts: $descriptionSlot — raw HTML to show in the About tab body
--}}
@php
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
$hasDesc = !empty($video->description) || isset($descriptionSlot);
$showBox = $hasDesc || $isVideoOwner;
$fullDescription = $video->description ?? '';
$shortDescription = Str::limit($fullDescription, 200);
$needsExpand = strlen($fullDescription) > 200;
@endphp
@if ($showBox)
<style>
/* ── Description box ─────────────────────────────────── */
.vdb-wrap { background:var(--bg-secondary); border-radius:12px; margin-top:12px; overflow:hidden; border:1px solid var(--border-color); }
.vdb-tabs { display:flex; border-bottom:1px solid var(--border-color); padding:0 4px; }
.vdb-tab { background:none; border:none; color:var(--text-secondary); font-size:13px; font-weight:600; padding:0 16px; height:44px; cursor:pointer; position:relative; transition:color .15s; white-space:nowrap; display:flex; align-items:center; gap:6px; }
.vdb-tab:hover { color:var(--text-primary); }
.vdb-tab.active { color:var(--text-primary); }
.vdb-tab.active::after { content:''; position:absolute; bottom:0; left:0; right:0; height:2px; background:#ef4444; border-radius:2px 2px 0 0; }
.vdb-panel { display:none; padding:14px 16px 16px; }
.vdb-panel.active { display:block; }
.vdb-meta { font-size:13px; font-weight:600; color:var(--text-secondary); margin-bottom:10px; display:flex; gap:10px; flex-wrap:wrap; }
.vdb-desc-text { font-size:14px; line-height:1.6; color:var(--text-primary); white-space:pre-wrap; }
.vdb-desc-text p { margin-bottom:8px; }
.vdb-desc-text a { color:#3ea6ff; }
.vdb-show-more { background:none; border:none; color:var(--text-primary); font-weight:700; font-size:13px; cursor:pointer; padding:6px 0 0; }
</style>
<div class="vdb-wrap" id="vdbWrap">
<div class="vdb-tabs">
<button class="vdb-tab active" data-panel="vdb-about" onclick="switchVdbTab('vdb-about',this)">
<i class="bi bi-card-text"></i> About
</button>
@if($isVideoOwner)
<button class="vdb-tab" data-panel="vdb-insights" onclick="switchVdbTab('vdb-insights',this)">
<i class="bi bi-bar-chart-line-fill"></i> Insights
</button>
@endif
</div>
<div class="vdb-panel active" id="vdb-about">
<div class="vdb-meta">
<span><i class="bi bi-eye" style="margin-right:4px;"></i>{{ number_format($video->view_count) }} views</span>
<span>•</span>
<span>{{ $video->created_at->format('M d, Y') }}</span>
@if($video->duration)<span>•</span><span><i class="bi bi-clock" style="margin-right:4px;"></i>{{ $video->formatted_duration }}</span>@endif
</div>
@if(isset($descriptionSlot))
{!! $descriptionSlot !!}
@elseif($hasDesc)
<div id="vdbDescShort" class="vdb-desc-text">{{ $needsExpand ? $shortDescription : $fullDescription }}</div>
@if($needsExpand)
<div id="vdbDescFull" class="vdb-desc-text" style="display:none;">{{ $fullDescription }}</div>
<button class="vdb-show-more" onclick="toggleVdbDesc(this)">Show more</button>
@endif
@else
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
@endif
</div>
@if($isVideoOwner)
<x-video-insights :video="$video" />
@endif
</div>
<script>
// ── Tab switching ──────────────────────────────────────
function switchVdbTab(panelId, btn) {
document.querySelectorAll('.vdb-tab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.vdb-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(panelId).classList.add('active');
if (panelId === 'vdb-insights' && !window._insLoaded) loadInsights();
}
function toggleVdbDesc(btn) {
const s = document.getElementById('vdbDescShort'), f = document.getElementById('vdbDescFull');
if (!f) return;
if (f.style.display === 'none') { s.style.display='none'; f.style.display='block'; btn.textContent='Show less'; }
else { s.style.display='block'; f.style.display='none'; btn.textContent='Show more'; }
}
</script>
@endif
@@ -17,7 +17,7 @@
<div class="channel-row" style="display: flex; align-items: center; justify-content: space-between; padding: 6px 0; flex-wrap: wrap; gap: 12px;">
{{-- Left: Channel Info --}}
<div style="display: flex; align-items: center; gap: 12px;">
<a href="{{ route('channel', $video->user_id) }}" class="channel-info text-decoration-none" style="color: inherit; display: flex; align-items: center; gap: 12px;">
<a href="{{ $video->user ? route('channel', $video->user->channel) : '#' }}" class="channel-info text-decoration-none" style="color: inherit; display: flex; align-items: center; gap: 12px;">
@if($video->user)
<img src="{{ $video->user->avatar_url }}" class="channel-avatar" style="width: 36px; height: 36px;" alt="{{ $video->user->name }}">
@else
@@ -47,30 +47,30 @@
<div class="video-actions">
@auth
{{-- Like Button with Icon and Count --}}
<form method="POST" action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}" class="d-inline">
<form method="POST" action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video) : route('videos.like', $video) }}" class="d-inline">
@csrf
<button type="submit" class="yt-action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}">
<button type="submit" class="action-btn {{ $video->isLikedBy(Auth::user()) ? 'action-btn-primary' : '' }}">
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
<span>{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}</span>
</button>
</form>
{{-- Edit Button - Only for video owner --}}
@if(Auth::id() === $video->user_id)
<button class="yt-action-btn" onclick="openEditVideoModal({{ $video->id }})">
<i class="bi bi-pencil"></i> Edit
<button class="action-btn" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
<i class="bi bi-pencil"></i> <span>Edit</span>
</button>
@endif
@else
<a href="{{ route('login') }}" class="yt-action-btn">
<i class="bi bi-hand-thumbs-up"></i> Like
<a href="{{ route('login') }}" class="action-btn">
<i class="bi bi-hand-thumbs-up"></i> <span>Like</span>
</a>
@endauth
{{-- Share Button --}}
@if($video->isShareable())
<button class="yt-action-btn" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
<i class="bi bi-share"></i> Share
<button class="action-btn" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
<i class="bi bi-share"></i> <span>Share</span>
</button>
@endif
</div>
@@ -190,8 +190,8 @@ function toggleDescription() {
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 12px; width: 100%; resize: none;"
></textarea>
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end;">
<button type="button" class="yt-action-btn" onclick="document.getElementById('commentBody').value = ''">Cancel</button>
<button type="button" class="yt-action-btn" style="background: var(--brand-red); color: white;" onclick="submitComment({{ $video->id }})">Comment</button>
<button type="button" class="action-btn" onclick="document.getElementById('commentBody').value = ''">Cancel</button>
<button type="button" class="action-btn action-btn-primary" onclick="submitComment('{{ $video->getRouteKey() }}')">Comment</button>
</div>
</div>
</div>
@@ -243,20 +243,20 @@ function loadComments(videoId) {
}
function deleteComment(commentId) {
if (!confirm('Are you sure you want to delete this comment?')) return;
fetch(`/comments/${commentId}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
}
});
showConfirm('Are you sure you want to delete this comment?', function() {
fetch(`/comments/${commentId}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
}
});
}, 'Delete');
}
// Highlight @mentions in comments
+551 -331
View File
@@ -2,349 +2,569 @@
@section('title', 'Shorts - ' . config('app.name'))
@section('main_class', 'shorts-view-page')
@section('extra_styles')
<style>
/* ── Make yt-main a scroll-snap container ── */
.yt-main.shorts-view-page {
padding: 0 !important;
scroll-snap-type: y mandatory;
scrollbar-width: none;
-ms-overflow-style: none;
}
.yt-main.shorts-view-page::-webkit-scrollbar { display: none; }
/* ── Each short occupies the full visible height ── */
.short-item {
height: calc(100vh - 56px); /* desktop: minus header */
scroll-snap-align: start;
scroll-snap-stop: always;
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
position: relative;
}
@media (max-width: 768px) {
.short-item {
height: calc(100vh - 112px); /* mobile: minus header + bottom nav */
gap: 0;
}
}
/* ── Video wrapper — 9:16 centered ── */
.short-player-wrap {
position: relative;
height: 92%;
max-height: 780px;
aspect-ratio: 9 / 16;
border-radius: 14px;
overflow: hidden;
background: #000;
flex-shrink: 0;
cursor: pointer;
}
@media (max-width: 768px) {
.short-player-wrap {
height: 100%;
max-height: unset;
width: 100%;
aspect-ratio: unset;
border-radius: 0;
}
}
.short-player-wrap video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* ── Tap-to-pause icon ── */
.short-pause-icon {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
opacity: 0;
transition: opacity .2s;
}
.short-pause-icon i {
font-size: 64px;
color: rgba(255,255,255,.85);
filter: drop-shadow(0 2px 8px rgba(0,0,0,.5));
}
.short-player-wrap.paused .short-pause-icon { opacity: 1; }
/* ── Info overlay at the bottom ── */
.short-info-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 48px 14px 16px;
background: linear-gradient(transparent, rgba(0,0,0,.72));
pointer-events: none;
}
.short-channel-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.short-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
border: 1.5px solid rgba(255,255,255,.6);
flex-shrink: 0;
}
.short-avatar-initial {
width: 32px;
height: 32px;
border-radius: 50%;
background: #e61e1e;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.short-channel-name {
font-size: 13px;
font-weight: 600;
color: #fff;
text-shadow: 0 1px 3px rgba(0,0,0,.5);
}
.short-title {
font-size: 14px;
font-weight: 500;
color: #fff;
margin: 0;
text-shadow: 0 1px 3px rgba(0,0,0,.5);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
.short-views {
font-size: 12px;
color: rgba(255,255,255,.75);
margin-top: 4px;
}
/* ── Right-side action buttons ── */
.short-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
flex-shrink: 0;
}
@media (max-width: 768px) {
.short-actions {
position: absolute;
right: 10px;
bottom: 80px;
gap: 16px;
}
}
.short-action-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
background: none;
border: none;
cursor: pointer;
padding: 0;
text-decoration: none;
color: inherit;
}
.short-action-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255,255,255,.12);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: #fff;
transition: background .2s, transform .15s;
backdrop-filter: blur(6px);
}
@media (min-width: 769px) {
.short-action-icon {
background: var(--bg-secondary);
color: var(--text-primary);
}
}
.short-action-btn:hover .short-action-icon,
.short-action-btn:active .short-action-icon {
background: rgba(255,255,255,.25);
transform: scale(1.08);
}
@media (min-width: 769px) {
.short-action-btn:hover .short-action-icon {
background: var(--border-color);
}
}
.short-action-label {
font-size: 12px;
font-weight: 500;
color: #fff;
}
@media (min-width: 769px) {
.short-action-label { color: var(--text-secondary); }
}
.short-action-btn.liked .short-action-icon { background: rgba(230,30,30,.3); color: #e61e1e; }
/* ── Up / Down nav arrows (desktop only) ── */
.short-nav-arrows {
position: absolute;
right: calc(50% - 230px - 80px);
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 12px;
}
@media (max-width: 900px) { .short-nav-arrows { display: none; } }
.short-nav-arrow {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background .2s;
}
.short-nav-arrow:hover { background: var(--border-color); }
/* ── Progress bar ── */
.short-progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(255,255,255,.3);
pointer-events: none;
}
.short-progress-fill {
height: 100%;
background: #fff;
width: 0%;
transition: width .1s linear;
}
/* ── Mute button ── */
.short-mute-btn {
position: absolute;
top: 14px;
right: 14px;
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(0,0,0,.45);
border: none;
color: #fff;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 2;
backdrop-filter: blur(4px);
transition: background .2s;
}
.short-mute-btn:hover { background: rgba(0,0,0,.7); }
/* ── Empty state ── */
.shorts-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 16px;
color: var(--text-secondary);
text-align: center;
padding: 32px;
}
.shorts-empty i { font-size: 64px; color: #ff0050; }
.shorts-empty h2 { font-size: 22px; font-weight: 600; color: var(--text-primary); margin: 0; }
.shorts-empty p { margin: 0; }
</style>
@endsection
@section('content')
<div class="yt-main">
<div class="yt-content">
<!-- Shorts Header -->
<div class="shorts-header">
<div class="shorts-brand">
<div class="shorts-logo-icon">
<i class="bi bi-play-fill"></i>
</div>
<h1>Shorts</h1>
</div>
</div>
<!-- Shorts Grid - YouTube Style -->
<div class="shorts-container">
@forelse($videos as $video)
<div class="shorts-card">
<a href="{{ route('videos.show', $video->id) }}" class="shorts-link">
<div class="shorts-thumbnail-wrapper">
<img src="{{ $video->thumbnail_url }}" alt="{{ $video->title }}" class="shorts-thumb">
<div class="shorts-overlay">
<span class="shorts-views">
<i class="bi bi-play-fill"></i> {{ number_format($video->view_count) }}
</span>
</div>
@if ($video->duration)
<span class="shorts-time">{{ gmdate('i:s', $video->duration) }}</span>
@endif
</div>
<div class="shorts-details">
<h3 class="shorts-title">{{ $video->title }}</h3>
<div class="shorts-channel-info">
@if ($video->user)
<div class="shorts-channel-row">
@if ($video->user->avatar_url)
<img src="{{ $video->user->avatar_url }}" class="shorts-avatar"
alt="{{ $video->user->name }}">
@else
<div class="shorts-avatar-placeholder">
{{ substr($video->user->name, 0, 1) }}</div>
@endif
<span class="shorts-channel-name">{{ $video->user->name }}</span>
</div>
@endif
<span class="shorts-ago">{{ $video->created_at->diffForHumans() }}</span>
</div>
</div>
</a>
</div>
@empty
<div class="shorts-empty-state">
<div class="shorts-empty-icon">
<i class="bi bi-collection-play"></i>
</div>
<h2>No Shorts yet</h2>
<p>Be the first to upload a Short!</p>
@auth
<a href="{{ route('videos.create') }}" class="shorts-upload-btn">
<i class="bi bi-plus-lg"></i> Upload Short
</a>
@else
<a href="{{ route('login') }}" class="shorts-upload-btn">
<i class="bi bi-box-arrow-in-right"></i> Login to Upload
</a>
@endauth
</div>
@endforelse
@forelse($videos as $video)
@php
$src = asset('storage/videos/' . $video->filename);
$thumb = $video->thumbnail_url;
$user = $video->user;
$index = $loop->index;
@endphp
<div class="short-item" data-id="{{ $video->id }}" data-index="{{ $index }}">
{{-- Up/Down nav (desktop) --}}
<div class="short-nav-arrows">
@if(!$loop->first)
<button class="short-nav-arrow" onclick="navigateShort(-1, {{ $index }})">
<i class="bi bi-chevron-up"></i>
</button>
@endif
@if(!$loop->last)
<button class="short-nav-arrow" onclick="navigateShort(1, {{ $index }})">
<i class="bi bi-chevron-down"></i>
</button>
@endif
</div>
{{-- Video player --}}
<div class="short-player-wrap" id="wrap-{{ $index }}" onclick="toggleShortPlay({{ $index }})">
<video
id="sv-{{ $index }}"
src="{{ $src }}"
poster="{{ $thumb }}"
loop
playsinline
preload="{{ $loop->first ? 'auto' : 'none' }}"
style="width:100%;height:100%;object-fit:cover;">
</video>
{{-- Pause icon --}}
<div class="short-pause-icon"><i class="bi bi-pause-fill"></i></div>
{{-- Info overlay --}}
<div class="short-info-overlay">
<div class="short-channel-row">
@if($user && $user->avatar_url)
<img src="{{ $user->avatar_url }}" class="short-avatar" alt="{{ $user->name }}">
@elseif($user)
<div class="short-avatar-initial">{{ substr($user->name, 0, 1) }}</div>
@endif
<span class="short-channel-name">{{ $user->name ?? '' }}</span>
</div>
<p class="short-title">{{ $video->title }}</p>
<div class="short-views"><i class="bi bi-play-fill"></i> {{ number_format($video->view_count) }} views</div>
</div>
{{-- Mute button --}}
<button class="short-mute-btn" id="mute-{{ $index }}" onclick="event.stopPropagation(); toggleMute({{ $index }})">
<i class="bi bi-volume-up-fill" id="mute-icon-{{ $index }}"></i>
</button>
{{-- Progress bar --}}
<div class="short-progress">
<div class="short-progress-fill" id="prog-{{ $index }}"></div>
</div>
</div>
<style>
/* Shorts Page - YouTube Shorts Style */
.shorts-header {
margin-bottom: 20px;
padding: 12px 0;
}
{{-- Action buttons --}}
<div class="short-actions">
{{-- Like --}}
@php $vKey = $video->getRouteKey(); @endphp
<button class="short-action-btn" id="like-btn-{{ $vKey }}" onclick="shortLike('{{ $vKey }}')">
<div class="short-action-icon"><i class="bi bi-heart{{ $video->isLikedBy(auth()->user()) ? '-fill' : '' }}" id="like-icon-{{ $vKey }}"></i></div>
<span class="short-action-label" id="like-count-{{ $vKey }}">{{ number_format($video->like_count) }}</span>
</button>
.shorts-brand {
display: flex;
align-items: center;
gap: 12px;
}
{{-- Comments --}}
<a class="short-action-btn" href="{{ route('videos.show', $video) }}#comments">
<div class="short-action-icon"><i class="bi bi-chat-fill"></i></div>
<span class="short-action-label">{{ number_format($video->comments()->count()) }}</span>
</a>
.shorts-logo-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #ff0050, #ff6b6b);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 20px;
}
{{-- Share --}}
<button class="short-action-btn" onclick="shortShare('{{ $vKey }}', {{ json_encode($video->title) }})">
<div class="short-action-icon"><i class="bi bi-share-fill"></i></div>
<span class="short-action-label">Share</span>
</button>
.shorts-brand h1 {
font-size: 22px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
{{-- Open full page --}}
<a class="short-action-btn" href="{{ route('videos.show', $video) }}">
<div class="short-action-icon"><i class="bi bi-box-arrow-up-right"></i></div>
<span class="short-action-label">Open</span>
</a>
</div>
/* Shorts Grid - 4 columns on desktop */
.shorts-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
</div>
@empty
/* Shorts Card */
.shorts-card {
background: transparent;
border-radius: 12px;
overflow: hidden;
transition: transform 0.2s;
}
<div class="shorts-empty">
<i class="bi bi-collection-play"></i>
<h2>No Shorts yet</h2>
<p>Shorts are vertical videos under 60 seconds.</p>
@auth
<a href="{{ route('videos.create') }}" class="action-btn action-btn-primary">
<i class="bi bi-plus-lg"></i> <span>Upload Short</span>
</a>
@else
<a href="{{ route('login') }}" class="action-btn action-btn-primary">
<i class="bi bi-box-arrow-in-right"></i> <span>Login to Upload</span>
</a>
@endauth
</div>
.shorts-card:hover {
transform: scale(1.02);
}
@endforelse
.shorts-link {
text-decoration: none;
color: inherit;
display: block;
}
.shorts-thumbnail-wrapper {
position: relative;
aspect-ratio: 9/16;
border-radius: 12px;
overflow: hidden;
background: #1a1a1a;
}
.shorts-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.shorts-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 8px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
opacity: 0;
transition: opacity 0.2s;
}
.shorts-card:hover .shorts-overlay {
opacity: 1;
}
.shorts-views {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
color: white;
}
.shorts-time {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.shorts-details {
padding: 10px 4px;
}
.shorts-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin: 0 0 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.3;
}
.shorts-channel-info {
display: flex;
flex-direction: column;
gap: 6px;
}
.shorts-channel-row {
display: flex;
align-items: center;
gap: 8px;
}
.shorts-avatar,
.shorts-avatar-placeholder {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.shorts-avatar-placeholder {
background: #666;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
}
.shorts-channel-name {
font-size: 12px;
color: var(--text-secondary);
}
.shorts-ago {
font-size: 11px;
color: var(--text-secondary);
opacity: 0.7;
}
/* Empty State */
.shorts-empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 60px 20px;
background: var(--bg-secondary);
border-radius: 16px;
}
.shorts-empty-icon {
width: 80px;
height: 80px;
margin: 0 auto 20px;
background: linear-gradient(135deg, #ff0050, #ff6b6b);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.shorts-empty-icon i {
font-size: 36px;
color: white;
}
.shorts-empty-state h2 {
font-size: 20px;
color: var(--text-primary);
margin: 0 0 8px;
}
.shorts-empty-state p {
color: var(--text-secondary);
margin: 0 0 20px;
}
.shorts-upload-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: #ff0050;
color: white;
border-radius: 20px;
text-decoration: none;
font-weight: 500;
font-size: 14px;
transition: background 0.2s;
}
.shorts-upload-btn:hover {
background: #e60048;
}
/* Pagination */
.shorts-pagination {
margin-top: 30px;
display: flex;
justify-content: center;
}
.shorts-pagination .pagination {
gap: 4px;
}
.shorts-pagination .page-link {
background: var(--bg-secondary);
border-color: var(--border-color);
color: var(--text-primary);
padding: 6px 12px;
font-size: 13px;
}
.shorts-pagination .page-item.active .page-link {
background: #ff0050;
border-color: #ff0050;
}
/* Responsive */
@media (max-width: 1200px) {
.shorts-container {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 900px) {
.shorts-container {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
}
@media (max-width: 600px) {
.shorts-container {
grid-template-columns: 1fr;
max-width: 320px;
margin: 0 auto;
}
.shorts-header {
text-align: center;
}
.shorts-brand {
justify-content: center;
}
}
</style>
@endsection
@section('scripts')
<script>
(function () {
const main = document.getElementById('main');
let muted = false;
let current = -1;
// ── IntersectionObserver — auto-play visible short ─────────
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
const item = entry.target;
const idx = parseInt(item.dataset.index);
const video = document.getElementById('sv-' + idx);
if (!video) return;
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
if (current !== -1 && current !== idx) pauseShort(current);
current = idx;
video.muted = muted;
video.volume = 1;
video.play().catch(() => {
// autoplay blocked — start muted then unmute
video.muted = true;
muted = true;
updateMuteIcon(idx);
video.play().catch(() => {});
});
document.getElementById('wrap-' + idx).classList.remove('paused');
} else {
pauseShort(idx);
}
});
}, { root: main, threshold: 0.6 });
document.querySelectorAll('.short-item').forEach(el => observer.observe(el));
// ── Play / pause on tap ────────────────────────────────────
window.toggleShortPlay = function(idx) {
const video = document.getElementById('sv-' + idx);
const wrap = document.getElementById('wrap-' + idx);
if (!video) return;
if (video.paused) {
video.play().catch(() => {});
wrap.classList.remove('paused');
} else {
video.pause();
wrap.classList.add('paused');
}
};
function pauseShort(idx) {
const video = document.getElementById('sv-' + idx);
const wrap = document.getElementById('wrap-' + idx);
if (video) { video.pause(); video.currentTime = 0; }
if (wrap) wrap.classList.remove('paused');
const prog = document.getElementById('prog-' + idx);
if (prog) prog.style.width = '0%';
}
// ── Progress bars ──────────────────────────────────────────
document.querySelectorAll('.short-item video').forEach(video => {
const idx = video.id.replace('sv-', '');
video.addEventListener('timeupdate', () => {
if (!video.duration) return;
const prog = document.getElementById('prog-' + idx);
if (prog) prog.style.width = ((video.currentTime / video.duration) * 100) + '%';
});
});
// ── Mute toggle ────────────────────────────────────────────
window.toggleMute = function(idx) {
muted = !muted;
const video = document.getElementById('sv-' + idx);
if (video) video.muted = muted;
updateMuteIcon(idx);
};
function updateMuteIcon(idx) {
const icon = document.getElementById('mute-icon-' + idx);
if (icon) icon.className = muted ? 'bi bi-volume-mute-fill' : 'bi bi-volume-up-fill';
}
// ── Desktop nav arrows ─────────────────────────────────────
window.navigateShort = function(dir, fromIndex) {
const target = document.querySelector('.short-item[data-index="' + (fromIndex + dir) + '"]');
if (target) target.scrollIntoView({ behavior: 'smooth' });
};
// ── Keyboard nav ───────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const dir = e.key === 'ArrowDown' ? 1 : -1;
if (current !== -1) navigateShort(dir, current);
}
if (e.key === 'm' || e.key === 'M') {
if (current !== -1) toggleMute(current);
}
});
// ── Like ───────────────────────────────────────────────────
window.shortLike = function(videoId) {
@auth
fetch('/videos/' + videoId + '/toggle-like', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' }
})
.then(r => r.json())
.then(data => {
const btn = document.getElementById('like-btn-' + videoId);
const icon = document.getElementById('like-icon-' + videoId);
const count = document.getElementById('like-count-' + videoId);
if (data.liked !== undefined) {
icon.className = data.liked ? 'bi bi-heart-fill' : 'bi bi-heart';
btn.classList.toggle('liked', data.liked);
}
if (data.like_count !== undefined) count.textContent = Number(data.like_count).toLocaleString();
})
.catch(() => {});
@else
window.location.href = '{{ route("login") }}';
@endauth
};
// ── Share ──────────────────────────────────────────────────
window.shortShare = function(videoKey, title) {
const url = window.location.origin + '/videos/' + videoKey;
if (navigator.share) {
navigator.share({ title: title, url: url }).catch(() => {});
} else if (typeof openShareModal === 'function') {
openShareModal(url, title);
} else {
navigator.clipboard.writeText(url).then(() => {
if (typeof showToast === 'function') showToast('Link copied!', 'success');
}).catch(() => {});
}
};
})();
</script>
@endsection
+180 -82
View File
@@ -266,6 +266,15 @@
gap: 8px;
margin-bottom: 8px;
cursor: pointer;
border-radius: 8px;
padding: 6px;
transition: background .15s;
}
.sidebar-video-card:hover { background: var(--bg-secondary); }
.sidebar-video-card.current-video {
background: rgba(230,30,30,.10);
border-left: 3px solid var(--brand-red);
padding-left: 9px;
}
.sidebar-thumb {
@@ -430,7 +439,7 @@
<div class="video-container @if ($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif"
id="videoContainer">
<video id="videoPlayer" controls playsinline preload="metadata" autoplay>
<source src="{{ route('videos.stream', $video->id) }}" type="video/mp4">
<source src="{{ route('videos.stream', $video) }}" type="video/mp4">
</video>
</div>
@@ -459,7 +468,7 @@
@auth
<!-- Like Button -->
<form method="POST"
action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}"
action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video) : route('videos.like', $video) }}"
class="d-inline">
@csrf
<button type="submit" class="yt-action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}">
@@ -471,7 +480,7 @@
<!-- Edit Button - Only for video owner -->
@if (Auth::id() === $video->user_id)
<button class="yt-action-btn" onclick="openEditVideoModal({{ $video->id }})">
<button class="yt-action-btn" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
<i class="bi bi-pencil"></i> Edit
</button>
@endif
@@ -482,7 +491,7 @@
@endauth
@if ($video->isShareable())
<button class="yt-action-btn"
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')"><i
onclick="videoShare('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')"><i
class="bi bi-share"></i> Share</button>
@endif
@@ -492,7 +501,7 @@
</button>
@auth
<!-- Quick Watch Later Button -->
<form method="POST" action="{{ route('videos.watchLater', $video->id) }}" class="d-inline"
<form method="POST" action="{{ route('videos.watchLater', $video) }}" class="d-inline"
style="display: inline;">
@csrf
<button type="submit" class="yt-action-btn" title="Watch Later">
@@ -507,7 +516,7 @@
<div class="channel-row"
style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px;">
<div style="display: flex; align-items: center; gap: 12px;">
<a href="{{ route('channel', $video->user_id) }}" class="channel-info text-decoration-none"
<a href="{{ $video->user ? route('channel', $video->user->channel) : '#' }}" class="channel-info text-decoration-none"
style="color: inherit; display: flex; align-items: center; gap: 12px;">
@if ($video->user)
<img src="{{ $video->user->avatar_url }}" class="channel-avatar"
@@ -533,72 +542,136 @@
</div>
</div>
<!-- Description -->
@if ($video->description)
@php
$fullDescription = $video->description;
$shortDescription = Str::limit($fullDescription, 200);
$needsExpand = strlen($fullDescription) > 200;
@endphp
{{-- ── Description / Insights box ── --}}
@php
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
$hasDesc = !empty($video->description);
$showBox = $hasDesc || $isVideoOwner;
@endphp
@if ($showBox)
@php
$fullDescription = $video->description ?? '';
$shortDescription = Str::limit($fullDescription, 200);
$needsExpand = strlen($fullDescription) > 200;
@endphp
<div class="video-description-box"
style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-top: 12px;">
<div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;">
<span>{{ number_format($video->view_count) }} views</span>
<span>•</span>
<span>{{ $video->created_at->format('M d, Y') }}</span>
</div>
<div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div>
<div class="description-content">
@if ($needsExpand)
<div class="description-short">
<span class="description-text">{!! Str::markdown($shortDescription) !!}</span>
<span style="color: var(--text-secondary);">... </span>
</div>
<div class="description-full" style="display: none;">
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
</div>
<button onclick="toggleDescription()"
style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">Show
more</button>
@else
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
@endif
</div>
<style>
/* ── Description box ── */
.vdb-wrap {
background: var(--bg-secondary);
border-radius: 12px;
margin-top: 12px;
overflow: hidden;
border: 1px solid var(--border-color);
}
.vdb-tabs {
display: flex;
border-bottom: 1px solid var(--border-color);
padding: 0 4px;
}
.vdb-tab {
background: none; border: none;
color: var(--text-secondary);
font-size: 13px; font-weight: 600;
padding: 0 16px; height: 44px;
cursor: pointer; position: relative;
transition: color .15s; white-space: nowrap;
display: flex; align-items: center; gap: 6px;
}
.vdb-tab:hover { color: var(--text-primary); }
.vdb-tab.active { color: var(--text-primary); }
.vdb-tab.active::after {
content: ''; position: absolute; bottom: 0; left: 0; right: 0;
height: 2px; background: #ef4444; border-radius: 2px 2px 0 0;
}
.vdb-panel { display: none; padding: 14px 16px 16px; }
.vdb-panel.active { display: block; }
/* About panel */
.vdb-meta { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 10px; display: flex; gap: 10px; flex-wrap: wrap; }
.vdb-desc-text { font-size: 14px; line-height: 1.6; color: var(--text-primary); white-space: pre-wrap; }
.vdb-desc-text p { margin-bottom: 8px; }
.vdb-desc-text a { color: #3ea6ff; }
.vdb-show-more { background: none; border: none; color: var(--text-primary); font-weight: 700; font-size: 13px; cursor: pointer; padding: 6px 0 0; }
</style>
<div class="vdb-wrap" id="vdbWrap">
{{-- Tab bar --}}
<div class="vdb-tabs">
<button class="vdb-tab active" data-panel="vdb-about" onclick="switchVdbTab('vdb-about',this)">
<i class="bi bi-card-text"></i> About
</button>
@if($isVideoOwner)
<button class="vdb-tab" data-panel="vdb-insights" onclick="switchVdbTab('vdb-insights',this)" id="insightsTabBtn">
<i class="bi bi-bar-chart-line-fill"></i> Insights
</button>
@endif
</div>
<script>
function toggleDescription() {
const short = document.querySelector('.description-short');
const full = document.querySelector('.description-full');
const btn = document.querySelector('.video-description-box button');
if (short.style.display !== 'none') {
short.style.display = 'none';
full.style.display = 'block';
btn.textContent = 'Show less';
} else {
short.style.display = 'block';
full.style.display = 'none';
btn.textContent = 'Show more';
}
}
</script>
{{-- About panel --}}
<div class="vdb-panel active" id="vdb-about">
<div class="vdb-meta">
<span><i class="bi bi-eye" style="margin-right:4px;"></i>{{ number_format($video->view_count) }} views</span>
<span>•</span>
<span>{{ $video->created_at->format('M d, Y') }}</span>
@if($video->duration)
<span>•</span>
<span><i class="bi bi-clock" style="margin-right:4px;"></i>{{ $video->formatted_duration }}</span>
@endif
</div>
@if($hasDesc)
<div id="vdbDescShort" class="vdb-desc-text">{{ $needsExpand ? $shortDescription : $fullDescription }}</div>
@if($needsExpand)
<div id="vdbDescFull" class="vdb-desc-text" style="display:none;">{{ $fullDescription }}</div>
<button class="vdb-show-more" onclick="toggleVdbDesc(this)">Show more</button>
@endif
@else
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
@endif
</div>
<style>
.video-description-box .description-text {
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
}
{{-- Insights panel (owner only) --}}
@if($isVideoOwner)
<x-video-insights :video="$video" />
@endif
</div>
.video-description-box .description-text p {
margin-bottom: 8px;
}
<script>
// ── Tab switching ──────────────────────────────────────
function switchVdbTab(panelId, btn) {
document.querySelectorAll('.vdb-tab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.vdb-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(panelId).classList.add('active');
if (panelId === 'vdb-insights' && !window._insLoaded) loadInsights();
}
.video-description-box .description-text a {
color: #3ea6ff;
}
</style>
// ── Description expand/collapse ───────────────────────
function toggleVdbDesc(btn) {
const short = document.getElementById('vdbDescShort');
const full = document.getElementById('vdbDescFull');
if (full.style.display === 'none') {
short.style.display = 'none';
full.style.display = 'block';
btn.textContent = 'Show less';
} else {
short.style.display = 'block';
full.style.display = 'none';
btn.textContent = 'Show more';
}
}
// ── Share + track ──────────────────────────────────────
function videoShare(url, title, trackUrl) {
openShareModal(url, title);
fetch(trackUrl, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '', 'Accept': 'application/json' }
}).catch(() => {});
}
</script>
@endif
@include('components.video-comments', ['video' => $video])
@@ -617,9 +690,10 @@
</h3>
<div class="recommended-videos-list">
@foreach ($playlistVideos as $index => $playlistVideo)
@if ($playlistVideo->id !== $video->id)
<div class="sidebar-video-card{{ $playlistVideo->id === $video->id ? ' current-video' : '' }}"
onclick="window.location.href='{{ route('videos.show', $playlistVideo->id) }}?playlist={{ $playlist->id }}'">
@php $isCurrent = $playlistVideo->id === $video->id; @endphp
<div class="sidebar-video-card{{ $isCurrent ? ' current-video' : '' }}"
{{ $isCurrent ? '' : "onclick=\"window.location.href='".route('videos.show', $playlistVideo)."?playlist={$playlist->share_token}'\"" }}
style="{{ $isCurrent ? 'cursor:default;' : 'cursor:pointer;' }}">
<div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
@@ -650,13 +724,16 @@
{{ Str::limit($playlistVideo->title, 60) }}
</div>
<div class="sidebar-meta">
<div>{{ $playlistVideo->user->name ?? 'Unknown' }}</div>
<div>{{ number_format($playlistVideo->view_count) }} views •
{{ $playlistVideo->created_at->diffForHumans() }}</div>
@if($isCurrent)
<div style="color:var(--brand-red);font-weight:600;font-size:11px;">▶ Now Playing</div>
@else
<div>{{ $playlistVideo->user->name ?? 'Unknown' }}</div>
<div>{{ number_format($playlistVideo->view_count) }} views •
{{ $playlistVideo->created_at->diffForHumans() }}</div>
@endif
</div>
</div>
</div>
@endif
@endforeach
</div>
@if ($playlist->canEdit(Auth::user()))
@@ -671,8 +748,8 @@
<div class="recommended-videos-list">
@foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card"
onclick="window.location.href='{{ route('videos.show', $recVideo->id) }}'">
<div class="sidebar-thumb">
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
@@ -718,7 +795,7 @@
<div class="mobile-bottom-bar">
@auth
<form method="POST"
action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}"
action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video) : route('videos.like', $video) }}"
class="d-inline" style="flex:1;">
@csrf
<button type="submit" class="yt-action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}"
@@ -766,12 +843,22 @@
@auth
<script>
document.addEventListener('DOMContentLoaded', function() {
openEditVideoModal({{ $video->id }});
openEditVideoModal('{{ $video->getRouteKey() }}');
});
</script>
@endauth
@endif
@if(session('_auto_dl_video') && $video->isAudioOnly())
<script>
document.addEventListener('DOMContentLoaded', function () {
if (typeof startSlideshowDownload === 'function') {
startSlideshowDownload('{{ $video->getRouteKey() }}');
}
});
</script>
@endif
<script>
document.addEventListener('DOMContentLoaded', function() {
var videoPlayer = document.getElementById('videoPlayer');
@@ -779,12 +866,23 @@
videoPlayer.volume = 0.5;
var playPromise = videoPlayer.play();
if (playPromise !== undefined) {
playPromise.then(function() {
console.log('Video autoplayed');
}).catch(function(error) {
console.log('Autoplay blocked');
});
playPromise.then(function() {}).catch(function() {});
}
@if($nextVideo && $playlist)
// Auto-advance to next playlist video when this one ends
videoPlayer.addEventListener('ended', function() {
window.location.href = '{{ route('videos.show', $nextVideo) }}?playlist={{ $playlist->share_token }}';
});
@endif
}
// Scroll the currently playing video into view in the playlist sidebar
var currentCard = document.querySelector('.sidebar-video-card.current-video');
if (currentCard) {
setTimeout(function() {
currentCard.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}, 400);
}
});
</script>
@@ -797,14 +797,14 @@
.then(response => response.json())
.catch(error => {
console.error('Error:', error);
alert('Failed to post comment: ' + error);
showToast('Failed to post comment: ' + error, 'error');
})
.then(data => {
if (data && data.success) {
document.getElementById('commentBody').value = '';
addCommentToList(data.comment);
} else {
alert('Failed to post comment');
showToast('Failed to post comment', 'error');
}
});
}
@@ -839,7 +839,7 @@
}
function deleteComment(commentId) {
if (confirm('Are you sure you want to delete this comment?')) {
showConfirm('Are you sure you want to delete this comment?', function() {
fetch(`/comments/${commentId}`, {
method: 'DELETE',
headers: {
@@ -857,7 +857,7 @@
}
}
});
}
}, 'Delete');
}
document.addEventListener('DOMContentLoaded', function() {
@@ -2517,14 +2517,14 @@
.then(response => response.json())
.catch(error => {
console.error('Error:', error);
alert('Failed to post comment: ' + error);
showToast('Failed to post comment: ' + error, 'error');
})
.then(data => {
if (data && data.success) {
document.getElementById('commentBody').value = '';
addCommentToList(data.comment);
} else {
alert('Failed to post comment');
showToast('Failed to post comment', 'error');
}
});
}
@@ -2668,7 +2668,7 @@
}
function deleteComment(commentId) {
if (confirm('Are you sure you want to delete this comment?')) {
showConfirm('Are you sure you want to delete this comment?', function() {
fetch(`/comments/${commentId}`, {
method: 'DELETE',
headers: {
@@ -2686,7 +2686,7 @@
}
}
});
}
}, 'Delete');
}
document.addEventListener('DOMContentLoaded', function() {
enhanceCommentBodyWithTimeBadges(document);
@@ -3510,7 +3510,7 @@
function requireValidTimeInput(value, fieldLabel) {
const parsed = parseMinuteSecondInput(value);
if (parsed === null) {
alert(`${fieldLabel} must be in mm.ss format (example: 1.30 for 1m 30s) or plain seconds.`);
showToast(`${fieldLabel} must be in mm.ss format (example: 1.30 for 1m 30s) or plain seconds.`, 'error');
return null;
}
return parsed;
@@ -3730,7 +3730,7 @@
// Round/Point/Review functions (abbreviated for brevity)
function openAddRoundModal() {
if (isDemoMode || !csrfToken) {
alert('Demo Mode: Requires authentication');
showToast('Demo Mode: Requires authentication', 'info');
return;
}
document.getElementById('roundNumber').value = '';
@@ -3758,15 +3758,15 @@
if (data.success) {
closeModal('addRoundModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
function editRound(roundNumber, roundId, currentName, currentStartTime = null) {
if (isDemoMode || !csrfToken) {
alert('Demo Mode');
showToast('Demo Mode', 'info');
return;
}
@@ -3834,7 +3834,7 @@
}
} catch (e) {
console.error('updateRound error:', e);
alert('Error updating round: ' + e.message);
showToast('Error updating round: ' + e.message, 'error');
}
}
async function deleteRound(roundId) {
@@ -3852,15 +3852,15 @@
});
const data = await res.json();
if (data.success) loadMatchData();
else alert(data.message || 'Error');
else showToast(data.message || 'Error', 'error');
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
function openAddPointModal(roundNumber, roundId) {
if (isDemoMode || !csrfToken) {
alert('Demo Mode');
showToast('Demo Mode', 'info');
return;
}
document.getElementById('pointModalTitle').textContent = 'Add Point';
@@ -3929,7 +3929,7 @@
if (data.success) {
closeModal('pointModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
} else {
const res = await fetch(`/videos/${videoId}/points`, {
method: 'POST',
@@ -3950,10 +3950,10 @@
if (data.success) {
closeModal('pointModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
}
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
async function deletePoint(pointId) {
@@ -3973,9 +3973,9 @@
if (data.success) {
closeModal('pointModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
@@ -3998,7 +3998,7 @@
function openAddReviewModal() {
if (isDemoMode || !csrfToken) {
alert('Demo Mode');
showToast('Demo Mode', 'info');
return;
}
const titleEl = document.getElementById('reviewModalTitle');
@@ -4077,7 +4077,7 @@
if (data.success) {
closeModal('reviewModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
} else {
const res = await fetch(`/videos/${videoId}/reviews`, {
method: 'POST',
@@ -4097,10 +4097,10 @@
if (data.success) {
closeModal('reviewModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
}
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
async function deleteReview(reviewId) {
@@ -4120,9 +4120,9 @@
if (data.success) {
closeModal('reviewModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
@@ -750,14 +750,14 @@
.then(response => response.json())
.catch(error => {
console.error('Error:', error);
alert('Failed to post comment: ' + error);
showToast('Failed to post comment: ' + error, 'error');
})
.then(data => {
if (data && data.success) {
document.getElementById('commentBody').value = '';
addCommentToList(data.comment);
} else {
alert('Failed to post comment');
showToast('Failed to post comment', 'error');
}
});
}
@@ -792,7 +792,7 @@
}
function deleteComment(commentId) {
if (confirm('Are you sure you want to delete this comment?')) {
showConfirm('Are you sure you want to delete this comment?', function() {
fetch(`/comments/${commentId}`, {
method: 'DELETE',
headers: {
@@ -810,7 +810,7 @@
}
}
});
}
}, 'Delete');
}
document.addEventListener('DOMContentLoaded', function() {
+29 -225
View File
@@ -1,7 +1,23 @@
@extends('layouts.app')
@section('main_class', 'video-view-page')
@section('title', $video->title . ' | ' . config('app.name'))
@push('head')
<meta property="og:title" content="{{ $video->title }}">
<meta property="og:description" content="{{ Str::limit(strip_tags($video->description ?? config('app.name') . ' — watch now'), 200) }}">
<meta property="og:image" content="{{ route('videos.ogImage', $video) }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:url" content="{{ $video->share_url }}">
<meta property="og:type" content="video.other">
<meta property="og:site_name" content="{{ config('app.name') }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $video->title }}">
<meta name="twitter:description" content="{{ Str::limit(strip_tags($video->description ?? ''), 200) }}">
<meta name="twitter:image" content="{{ route('videos.ogImage', $video) }}">
@endpush
@section('extra_styles')
<style>
/* Video Section */
@@ -326,10 +342,6 @@
justify-content: flex-start;
}
.yt-main {
padding: 12px !important;
}
.video-container {
max-height: 50vh !important;
border-radius: 0 !important;
@@ -389,53 +401,11 @@
<!-- Video Section -->
<div class="yt-video-section">
<!-- Video Player -->
<div class="video-container @if ($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif"
id="videoContainer">
<video id="videoPlayer" controls playsinline preload="metadata" autoplay>
<source src="{{ route('videos.stream', $video->id) }}" type="video/mp4">
</video>
<!-- Playlist Navigation Controls (only show when in playlist) -->
@if ($nextVideo || $previousVideo)
<div class="playlist-controls" id="playlistControls">
<!-- Previous Video -->
@if ($previousVideo)
<button class="playlist-nav-btn" onclick="goToPreviousVideo()" title="Previous video">
<i class="bi bi-skip-backward-fill"></i>
</button>
<span class="playlist-nav-label">{{ Str::limit($previousVideo->title, 20) }}</span>
@else
<button class="playlist-nav-btn" disabled title="No previous video">
<i class="bi bi-skip-backward-fill"></i>
</button>
@endif
<!-- Autoplay Toggle -->
<div class="autoplay-toggle">
<label for="autoplayToggle">Autoplay</label>
<label class="autoplay-switch">
<input type="checkbox" id="autoplayToggle" checked>
<span class="autoplay-slider"></span>
</label>
</div>
<!-- Next Video -->
@if ($nextVideo)
<span class="playlist-nav-label">{{ Str::limit($nextVideo->title, 20) }}</span>
<button class="playlist-nav-btn" onclick="goToNextVideo()" title="Next video">
<i class="bi bi-skip-forward-fill"></i>
</button>
@else
<button class="playlist-nav-btn" disabled title="No next video">
<i class="bi bi-skip-forward-fill"></i>
</button>
@endif
</div>
<div class="keyboard-hint">
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-right"></i>
</div>
@endif
</div>
<x-video-player :video="$video"
:next-video="$nextVideo ?? null"
:previous-video="$previousVideo ?? null"
:playlist="$playlist ?? null"
:playlist-videos="$playlistVideos ?? null" />
<!-- Video Title with Film Icon (Generic Type) -->
<h1 class="video-title" style="display: flex; align-items: center; gap: 10px; margin: 8px 0 6px;">
@@ -454,75 +424,7 @@
<x-channel-row :video="$video" />
<!-- Description Box -->
@if ($video->description)
@php
$fullDescription = $video->description;
$shortDescription = Str::limit($fullDescription, 200);
$needsExpand = strlen($fullDescription) > 200;
@endphp
<div class="video-description-box"
style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-bottom: 16px;">
<div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;">
<span>{{ number_format($video->view_count) }} views</span>
<span>•</span>
<span>{{ $video->created_at->format('M d, Y') }}</span>
</div>
<div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div>
<div class="description-content" id="descriptionContent">
@if ($needsExpand)
<div class="description-short" id="descShort">
<span class="description-text">{!! Str::markdown($shortDescription) !!}</span>
<span class="description-ellipsis" style="color: var(--text-secondary);">... </span>
</div>
<div class="description-full" id="descFull" style="display: none;">
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
</div>
<button onclick="toggleDescription()" id="descToggleBtn"
style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">
Show more
</button>
@else
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
@endif
</div>
</div>
<script>
function toggleDescription() {
const descShort = document.getElementById('descShort');
const descFull = document.getElementById('descFull');
const toggleBtn = document.getElementById('descToggleBtn');
if (descShort.style.display !== 'none') {
descShort.style.display = 'none';
descFull.style.display = 'block';
toggleBtn.textContent = 'Show less';
} else {
descShort.style.display = 'block';
descFull.style.display = 'none';
toggleBtn.textContent = 'Show more';
}
}
</script>
<style>
.video-description-box .description-text {
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
}
.video-description-box .description-text p {
margin-bottom: 8px;
}
.video-description-box .description-text a {
color: #3ea6ff;
}
</style>
@endif
@include('videos.partials.description-box', ['video' => $video])
<x-video-comments :video="$video" />
</div>
@@ -537,7 +439,7 @@
</script>
<!-- Sidebar - Up Next / Recommendations -->
<div class="yt-sidebar-container">
<div class="yt-sidebar-container" style="display: flex; flex-direction: column; gap: 16px;">
@if ($playlist && $playlistVideos && $playlistVideos->count() > 0)
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">
<i class="bi bi-collection-play" style="margin-right: 8px;"></i>
@@ -550,7 +452,7 @@
@foreach ($playlistVideos as $index => $playlistVideo)
@if ($playlistVideo->id !== $video->id)
<div class="sidebar-video-card{{ $playlistVideo->id === $video->id ? ' current-video' : '' }}"
onclick="window.location.href='{{ route('videos.show', $playlistVideo->id) }}?playlist={{ $playlist->id }}'">
onclick="window.location.href='{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
@@ -592,9 +494,8 @@
@endforeach
</div>
@if ($playlist->canEdit(Auth::user()))
<a href="{{ route('playlists.show', $playlist->id) }}" class="yt-action-btn"
style="margin-top: 12px; display: inline-block;">
<i class="bi bi-pencil"></i> Edit Playlist
<a href="{{ route('playlists.show', $playlist->id) }}" class="action-btn" style="margin-top: 12px;">
<i class="bi bi-pencil"></i> <span>Edit Playlist</span>
</a>
@endif
@else
@@ -603,8 +504,8 @@
<div class="recommended-videos-list">
@foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card"
onclick="window.location.href='{{ route('videos.show', $recVideo->id) }}'">
<div class="sidebar-thumb">
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
@@ -645,113 +546,16 @@
</div>
@include('layouts.partials.share-modal')
@include('layouts.partials.edit-video-modal')
@if (Session::has('openEditModal') && Session::get('openEditModal'))
@auth
<script>
document.addEventListener('DOMContentLoaded', function() {
openEditVideoModal({{ $video->id }});
openEditVideoModal('{{ $video->getRouteKey() }}');
});
</script>
@endauth
@endif
<script>
// Playlist navigation URLs
const previousVideoUrl = @json($previousVideo ? route('videos.show', $previousVideo->id) . '?playlist=' . $playlist->id : null);
const nextVideoUrl = @json($nextVideo ? route('videos.show', $nextVideo->id) . '?playlist=' . $playlist->id : null);
// Autoplay management
let autoplayEnabled = true;
document.addEventListener('DOMContentLoaded', function() {
var videoPlayer = document.getElementById('videoPlayer');
var playlistControls = document.getElementById('playlistControls');
var autoplayToggle = document.getElementById('autoplayToggle');
if (videoPlayer) {
videoPlayer.volume = 0.5;
var playPromise = videoPlayer.play();
if (playPromise !== undefined) {
playPromise.then(function() {
console.log('Video autoplayed');
}).catch(function(error) {
console.log('Autoplay blocked');
});
}
// Handle video ended - autoplay next
if (nextVideoUrl) {
videoPlayer.addEventListener('ended', function() {
if (autoplayEnabled) {
console.log('Video ended, going to next...');
window.location.href = nextVideoUrl;
}
});
}
}
// Show playlist controls on hover
if (playlistControls) {
const videoContainer = document.getElementById('videoContainer');
videoContainer.addEventListener('mouseenter', function() {
playlistControls.classList.add('visible');
});
videoContainer.addEventListener('mouseleave', function() {
playlistControls.classList.remove('visible');
});
// Initially show for a few seconds
setTimeout(function() {
playlistControls.classList.add('visible');
}, 2000);
setTimeout(function() {
playlistControls.classList.remove('visible');
}, 6000);
}
// Autoplay toggle handler
if (autoplayToggle) {
autoplayToggle.addEventListener('change', function() {
autoplayEnabled = this.checked;
localStorage.setItem('playlistAutoplay', autoplayEnabled ? '1' : '0');
});
// Load saved preference
const savedAutoplay = localStorage.getItem('playlistAutoplay');
if (savedAutoplay !== null) {
autoplayEnabled = savedAutoplay === '1';
autoplayToggle.checked = autoplayEnabled;
}
}
// Keyboard navigation
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
goToPreviousVideo();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
goToNextVideo();
}
});
});
function goToPreviousVideo() {
if (previousVideoUrl) {
window.location.href = previousVideoUrl;
}
}
function goToNextVideo() {
if (nextVideoUrl) {
window.location.href = nextVideoUrl;
}
}
</script>
@endsection
+84 -212
View File
@@ -1,6 +1,22 @@
@extends('layouts.app')
@section('main_class', 'video-view-page')
@section('title', ($video->title ?? 'Match Video') . ' | ' . config('app.name'))
@push('head')
<meta property="og:title" content="{{ $video->title }}">
<meta property="og:description" content="{{ Str::limit(strip_tags($video->description ?? config('app.name') . ' — watch now'), 200) }}">
<meta property="og:image" content="{{ route('videos.ogImage', $video) }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:url" content="{{ $video->share_url }}">
<meta property="og:type" content="video.other">
<meta property="og:site_name" content="{{ config('app.name') }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $video->title }}">
<meta name="twitter:description" content="{{ Str::limit(strip_tags($video->description ?? ''), 200) }}">
<meta name="twitter:image" content="{{ route('videos.ogImage', $video) }}">
@endpush
@section('extra_styles')
<style>
/* ===== Video Section ===== */
@@ -332,28 +348,6 @@
gap: 8px;
}
.yt-action-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 20px;
border: none;
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.yt-action-btn:hover {
background: var(--border-color);
}
.yt-action-btn.liked {
color: var(--brand-red);
}
/* ===== Channel Row ===== */
.channel-row {
display: flex;
@@ -976,7 +970,7 @@
overflow: hidden;
width: 400px;
flex-shrink: 0;
height: var(--video-height, 70vh);
height: var(--sidebar-height, 400px);
display: none !important;
flex-direction: column;
}
@@ -1102,22 +1096,6 @@
color: var(--text-primary);
}
.btn-add {
background: var(--brand-red);
color: white;
border: none;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.btn-add:hover {
background: #dc2626;
}
.event-list {
margin-top: 12px;
@@ -1852,10 +1830,6 @@
justify-content: flex-start;
}
.yt-main {
padding: 12px !important;
}
.video-container {
max-height: 50vh !important;
border-radius: 0 !important;
@@ -1977,59 +1951,20 @@
<!-- Video Section -->
<div class="yt-video-section">
<!-- Video Player -->
<div class="video-container @if (isset($video->orientation) && $video->orientation === 'portrait') portrait @elseif(isset($video->orientation) && $video->orientation === 'square') square @elseif(isset($video->orientation) && $video->orientation === 'ultrawide') ultrawide @endif"
id="videoContainer">
<video id="videoPlayer" controls playsinline preload="metadata" autoplay>
<source src="{{ isset($video) ? route('videos.stream', $video->id) : '#' }}" type="video/mp4">
</video>
<!-- Match Highlights Toggle Button -->
<button class="match-highlights-toggle" id="matchHighlightsToggle" title="Toggle Match Highlights">
<span>Highlights</span>
</button>
<!-- Coach note overlay (subtitle-style) -->
<div class="coach-note-overlay" id="coachNoteOverlay"></div>
<!-- Playlist Navigation Controls -->
@if (isset($nextVideo) || isset($previousVideo))
<div class="playlist-controls" id="playlistControls">
@if (isset($previousVideo))
<button class="playlist-nav-btn" onclick="goToPreviousVideo()" title="Previous video">
<i class="bi bi-skip-backward-fill"></i>
</button>
<span
class="playlist-nav-label">{{ Str::limit($previousVideo->title ?? 'Previous', 20) }}</span>
@else
<button class="playlist-nav-btn" disabled title="No previous video">
<i class="bi bi-skip-backward-fill"></i>
</button>
@endif
<div class="autoplay-toggle">
<label for="autoplayToggle">Autoplay</label>
<label class="autoplay-switch">
<input type="checkbox" id="autoplayToggle" checked>
<span class="autoplay-slider"></span>
</label>
</div>
@if (isset($nextVideo))
<span class="playlist-nav-label">{{ Str::limit($nextVideo->title ?? 'Next', 20) }}</span>
<button class="playlist-nav-btn" onclick="goToNextVideo()" title="Next video">
<i class="bi bi-skip-forward-fill"></i>
</button>
@else
<button class="playlist-nav-btn" disabled title="No next video">
<i class="bi bi-skip-forward-fill"></i>
</button>
@endif
</div>
<div class="keyboard-hint">
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-right"></i>
</div>
@endif
</div>
<x-video-player :video="$video"
:next-video="$nextVideo ?? null"
:previous-video="$previousVideo ?? null"
:playlist="$playlist ?? null"
:playlist-videos="$playlistVideos ?? null">
<x-slot:overlay>
{{-- Match highlights toggle --}}
<button class="match-highlights-toggle" id="matchHighlightsToggle" title="Toggle Match Highlights">
<span>Highlights</span>
</button>
{{-- Coach note overlay (subtitle-style) --}}
<div class="coach-note-overlay" id="coachNoteOverlay"></div>
</x-slot:overlay>
</x-video-player>
<!-- ===== EVENT HEADER ===== -->
<div class="event-header">
@@ -2088,8 +2023,10 @@
</div>
<!-- ===== END EVENT HEADER ===== -->
<!-- Description Box (Collapsible) -->
@if (isset($video) && $video->description)
@include('videos.partials.description-box', ['video' => $video])
{{-- ---- legacy match description panel (kept for reference, hidden) ---- --}}
@if (false && isset($video) && $video->description)
@php
$fullDescription = $video->description;
$shortDescription = Str::limit($fullDescription, 200);
@@ -2272,33 +2209,11 @@
</section>
<script>
// Collapsible Description Toggle
const descriptionBody = document.getElementById('descriptionBody');
const descriptionToggle = document.getElementById('toggleDescription');
const descriptionToggleLabel = document.getElementById('descriptionToggleLabel');
const descriptionToggleIcon = document.getElementById('descriptionToggleIcon');
if (descriptionToggle && descriptionBody) {
descriptionToggle.addEventListener('click', () => {
const isOpen = descriptionBody.classList.toggle('open');
descriptionToggleLabel.textContent = isOpen ? 'Hide description' : 'Show description';
descriptionToggleIcon.textContent = isOpen ? '▲' : '▼';
});
}
// Hall/Venue Link Handler
const hallLink = document.getElementById('hallLink');
if (hallLink) {
hallLink.addEventListener('click', () => {
// Analytics tracking can be added here
});
}
// Match Highlights Toggle with localStorage persistence
document.addEventListener('DOMContentLoaded', function() {
const toggleBtn = document.getElementById('matchHighlightsToggle');
const sidebar = document.querySelector('.events-sidebar');
const videoId = {{ isset($video) ? $video->id : 0 }};
const videoId = {{ isset($video) ? json_encode($video->getRouteKey()) : "''" }};
if (toggleBtn && sidebar) {
// Load saved state on page load
@@ -2351,7 +2266,7 @@
</div>
@auth
@if (isset($video) && Auth::id() === $video->user_id)
<button class="btn-add" type="button" onclick="openAddRoundModal()">
<button class="action-btn action-btn-primary" type="button" onclick="openAddRoundModal()">
<span>+</span> Add Round
</button>
@endif
@@ -2524,7 +2439,7 @@
<div class="section-label">Private notes</div>
@auth
@if (isset($video) && Auth::id() === $video->user_id)
<button class="btn-add" type="button" onclick="openAddReviewModal()">
<button class="action-btn action-btn-primary" type="button" onclick="openAddReviewModal()">
<span>+</span> Add Note
</button>
@endif
@@ -2625,7 +2540,7 @@
@foreach ($playlistVideos as $index => $playlistVideo)
@if ($playlistVideo->id !== $video->id)
<div class="sidebar-video-card{{ $playlistVideo->id === $video->id ? ' current-video' : '' }}"
onclick="window.location.href='{{ route('videos.show', $playlistVideo->id) }}?playlist={{ $playlist->id }}'">
onclick="window.location.href='{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
@@ -2667,9 +2582,8 @@
@endforeach
</div>
@if ($playlist->canEdit(Auth::user()))
<a href="{{ route('playlists.show', $playlist->id) }}" class="yt-action-btn"
style="margin-top: 12px; display: inline-block;">
<i class="bi bi-pencil"></i> Edit Playlist
<a href="{{ route('playlists.show', $playlist->id) }}" class="action-btn" style="margin-top: 12px;">
<i class="bi bi-pencil"></i> <span>Edit Playlist</span>
</a>
@endif
@else
@@ -2678,8 +2592,8 @@
<div class="recommended-videos-list">
@foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card"
onclick="window.location.href='{{ route('videos.show', $recVideo->id) }}'">
<div class="sidebar-thumb">
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
@@ -2723,7 +2637,6 @@
</div>
<!-- Modals -->
@include('layouts.partials.share-modal')
@include('layouts.partials.edit-video-modal')
<!-- Add Round Modal -->
@@ -2931,7 +2844,7 @@
@auth
<script>
document.addEventListener('DOMContentLoaded', function() {
openEditVideoModal({{ isset($video) ? $video->id : 0 }});
openEditVideoModal('{{ isset($video) ? $video->getRouteKey() : '' }}');
});
</script>
@endauth
@@ -2956,12 +2869,8 @@
});
}
// Playlist navigation URLs
const previousVideoUrl = @json(isset($previousVideo) ? route('videos.show', $previousVideo->id) . '?playlist=' . ($playlist->id ?? '') : null);
const nextVideoUrl = @json(isset($nextVideo) ? route('videos.show', $nextVideo->id) . '?playlist=' . ($playlist->id ?? '') : null);
const videoId = {{ isset($video) ? $video->id : 0 }};
const videoId = {{ isset($video) ? json_encode($video->getRouteKey()) : "''" }};
const isOwner = {{ json_encode(Auth::check() && isset($video) && Auth::id() === $video->user_id) }};
let autoplayEnabled = true;
document.addEventListener('DOMContentLoaded', function() {
loadMatchData();
@@ -3000,9 +2909,9 @@
const pointsContainer = document.getElementById('officialEvents');
const reviewsContainer = document.getElementById('reviewEvents');
if (pointsContainer) pointsContainer.innerHTML =
'<div style="text-align: center; padding: 40px; color: var(--text-secondary);">No match data yet. <button class="btn-add" onclick="openAddRoundModal()">+ Add Round</button></div>';
'<div style="text-align: center; padding: 40px; color: var(--text-secondary);">No match data yet. <button class="action-btn action-btn-primary" onclick="openAddRoundModal()">+ Add Round</button></div>';
if (reviewsContainer) reviewsContainer.innerHTML =
'<div style="text-align: center; padding: 40px; color: var(--text-secondary);">No coach notes yet. <button class="btn-add" onclick="openAddReviewModal()">+ Add Note</button></div>';
'<div style="text-align: center; padding: 40px; color: var(--text-secondary);">No coach notes yet. <button class="action-btn action-btn-primary" onclick="openAddReviewModal()">+ Add Note</button></div>';
}
function renderMatchData(rounds, reviews) {
@@ -3124,12 +3033,29 @@
function requireValidTimeInput(value, fieldLabel) {
const parsed = parseMinuteSecondInput(value);
if (parsed === null) {
alert(`${fieldLabel} must be in mm.ss format (example: 1.30 for 1m 30s) or plain seconds.`);
showToast(`${fieldLabel} must be in mm.ss format (example: 1.30 for 1m 30s) or plain seconds.`, 'error');
return null;
}
return parsed;
}
// Sync sidebar height to video player height
function initSidebarHeightSync() {
const wrap = document.getElementById('ytpWrap');
if (!wrap) return;
const apply = () => {
const h = wrap.getBoundingClientRect().height;
if (h > 0) document.documentElement.style.setProperty('--sidebar-height', h + 'px');
};
apply();
new ResizeObserver(apply).observe(wrap);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSidebarHeightSync);
} else {
initSidebarHeightSync();
}
document.addEventListener('DOMContentLoaded', function() {
const tabButtons = document.querySelectorAll('.tab-button');
const tabPanels = document.querySelectorAll('.tab-panel');
@@ -3344,7 +3270,7 @@
// Round/Point/Review functions (abbreviated for brevity)
function openAddRoundModal() {
if (isDemoMode || !csrfToken) {
alert('Demo Mode: Requires authentication');
showToast('Demo Mode: Requires authentication', 'info');
return;
}
document.getElementById('roundNumber').value = '';
@@ -3372,15 +3298,15 @@
if (data.success) {
closeModal('addRoundModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
function editRound(roundNumber, roundId, currentName, currentStartTime = null) {
if (isDemoMode || !csrfToken) {
alert('Demo Mode');
showToast('Demo Mode', 'info');
return;
}
@@ -3448,7 +3374,7 @@
}
} catch (e) {
console.error('updateRound error:', e);
alert('Error updating round: ' + e.message);
showToast('Error updating round: ' + e.message, 'error');
}
}
async function deleteRound(roundId) {
@@ -3466,15 +3392,15 @@
});
const data = await res.json();
if (data.success) loadMatchData();
else alert(data.message || 'Error');
else showToast(data.message || 'Error', 'error');
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
function openAddPointModal(roundNumber, roundId) {
if (isDemoMode || !csrfToken) {
alert('Demo Mode');
showToast('Demo Mode', 'info');
return;
}
document.getElementById('pointModalTitle').textContent = 'Add Point';
@@ -3543,7 +3469,7 @@
if (data.success) {
closeModal('pointModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
} else {
const res = await fetch(`/videos/${videoId}/points`, {
method: 'POST',
@@ -3564,10 +3490,10 @@
if (data.success) {
closeModal('pointModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
}
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
async function deletePoint(pointId) {
@@ -3587,9 +3513,9 @@
if (data.success) {
closeModal('pointModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
@@ -3612,7 +3538,7 @@
function openAddReviewModal() {
if (isDemoMode || !csrfToken) {
alert('Demo Mode');
showToast('Demo Mode', 'info');
return;
}
const titleEl = document.getElementById('reviewModalTitle');
@@ -3691,7 +3617,7 @@
if (data.success) {
closeModal('reviewModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
} else {
const res = await fetch(`/videos/${videoId}/reviews`, {
method: 'POST',
@@ -3711,10 +3637,10 @@
if (data.success) {
closeModal('reviewModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
}
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
async function deleteReview(reviewId) {
@@ -3734,65 +3660,11 @@
if (data.success) {
closeModal('reviewModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
// Playlist navigation
document.addEventListener('DOMContentLoaded', function() {
const videoPlayer = document.getElementById('videoPlayer');
const playlistControls = document.getElementById('playlistControls');
const autoplayToggle = document.getElementById('autoplayToggle');
if (videoPlayer) {
videoPlayer.volume = 0.5;
const playPromise = videoPlayer.play();
if (playPromise !== undefined) {
playPromise.then(() => console.log('Autoplayed')).catch(() => console.log('Autoplay blocked'));
}
if (nextVideoUrl) {
videoPlayer.addEventListener('ended', () => {
if (autoplayEnabled) window.location.href = nextVideoUrl;
});
}
}
if (playlistControls) {
const videoContainer = document.getElementById('videoContainer');
videoContainer.addEventListener('mouseenter', () => playlistControls.classList.add('visible'));
videoContainer.addEventListener('mouseleave', () => playlistControls.classList.remove('visible'));
setTimeout(() => playlistControls.classList.add('visible'), 2000);
setTimeout(() => playlistControls.classList.remove('visible'), 6000);
}
if (autoplayToggle) {
autoplayToggle.addEventListener('change', function() {
autoplayEnabled = this.checked;
localStorage.setItem('playlistAutoplay', autoplayEnabled ? '1' : '0');
});
const saved = localStorage.getItem('playlistAutoplay');
if (saved !== null) {
autoplayEnabled = saved === '1';
autoplayToggle.checked = autoplayEnabled;
}
}
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
if (previousVideoUrl) window.location.href = previousVideoUrl;
} else if (e.key === 'ArrowRight') {
e.preventDefault();
if (nextVideoUrl) window.location.href = nextVideoUrl;
}
});
});
function goToPreviousVideo() {
if (previousVideoUrl) window.location.href = previousVideoUrl;
}
function goToNextVideo() {
if (nextVideoUrl) window.location.href = nextVideoUrl;
}
</script>
@endsection
+35 -249
View File
@@ -1,7 +1,23 @@
@extends('layouts.app')
@section('main_class', 'video-view-page')
@section('title', $video->title . ' | ' . config('app.name'))
@push('head')
<meta property="og:title" content="{{ $video->title }}">
<meta property="og:description" content="{{ Str::limit(strip_tags($video->description ?? config('app.name') . ' — watch now'), 200) }}">
<meta property="og:image" content="{{ route('videos.ogImage', $video) }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:url" content="{{ $video->share_url }}">
<meta property="og:type" content="video.other">
<meta property="og:site_name" content="{{ config('app.name') }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $video->title }}">
<meta name="twitter:description" content="{{ Str::limit(strip_tags($video->description ?? ''), 200) }}">
<meta name="twitter:image" content="{{ route('videos.ogImage', $video) }}">
@endpush
@section('extra_styles')
<style>
/* Video Section */
@@ -216,24 +232,6 @@
gap: 8px;
}
.yt-action-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 20px;
border: none;
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.yt-action-btn:hover {
background: var(--border-color);
}
.action-btn,
.comments-section .action-btn {
border: none;
@@ -275,11 +273,6 @@
border-color: var(--brand-red);
}
.yt-action-btn.liked {
color: var(--brand-red);
}
/* Description */
.video-description {
@@ -395,10 +388,6 @@
justify-content: flex-start;
}
.yt-main {
padding: 12px !important;
}
.video-container {
max-height: 50vh !important;
border-radius: 0 !important;
@@ -441,54 +430,17 @@
<!-- Video Section -->
<div class="yt-video-section">
<!-- Video Player -->
<div class="video-container @if ($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif"
id="videoContainer">
<video id="videoPlayer" controls playsinline preload="metadata" autoplay>
<source src="{{ route('videos.stream', $video->id) }}" type="video/mp4">
</video>
<!-- Playlist Navigation Controls (only show when in playlist) -->
@if ($nextVideo || $previousVideo)
<div class="playlist-controls" id="playlistControls">
<!-- Previous Video -->
@if ($previousVideo)
<button class="playlist-nav-btn" onclick="goToPreviousVideo()" title="Previous video">
<i class="bi bi-skip-backward-fill"></i>
</button>
<span class="playlist-nav-label">{{ Str::limit($previousVideo->title, 20) }}</span>
@else
<button class="playlist-nav-btn" disabled title="No previous video">
<i class="bi bi-skip-backward-fill"></i>
</button>
@endif
<!-- Autoplay Toggle -->
<div class="autoplay-toggle">
<label for="autoplayToggle">Autoplay</label>
<label class="autoplay-switch">
<input type="checkbox" id="autoplayToggle" checked>
<span class="autoplay-slider"></span>
</label>
</div>
<!-- Next Video -->
@if ($nextVideo)
<span class="playlist-nav-label">{{ Str::limit($nextVideo->title, 20) }}</span>
<button class="playlist-nav-btn" onclick="goToNextVideo()" title="Next video">
<i class="bi bi-skip-forward-fill"></i>
</button>
@else
<button class="playlist-nav-btn" disabled title="No next video">
<i class="bi bi-skip-forward-fill"></i>
</button>
@endif
</div>
<div class="keyboard-hint">
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-right"></i>
</div>
@endif
</div>
@if($video->isAudioOnly())
{{-- ── Audio-only player ── --}}
@include('videos.partials.audio-player', ['video' => $video, 'nextVideo' => $nextVideo ?? null, 'previousVideo' => $previousVideo ?? null, 'playlist' => $playlist ?? null])
@else
{{-- ── Video player ── --}}
<x-video-player :video="$video"
:next-video="$nextVideo ?? null"
:previous-video="$previousVideo ?? null"
:playlist="$playlist ?? null"
:playlist-videos="$playlistVideos ?? null" />
@endif
<!-- Video Title with Music Note Icon (Music Type) -->
<h1 class="video-title" style="display: flex; align-items: center; gap: 10px; margin: 8px 0 6px;">
@@ -508,82 +460,14 @@
<!-- Channel Row - All in one line -->
<x-channel-row :video="$video" />
<!-- Description Box -->
@if ($video->description)
@php
$fullDescription = $video->description;
$shortDescription = Str::limit($fullDescription, 200);
$needsExpand = strlen($fullDescription) > 200;
@endphp
<div class="video-description-box"
style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-bottom: 16px;">
<div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;">
<span>{{ number_format($video->view_count) }} views</span>
<span>•</span>
<span>{{ $video->created_at->format('M d, Y') }}</span>
</div>
<div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div>
<div class="description-content" id="descriptionContent">
@if ($needsExpand)
<div class="description-short" id="descShort">
<span class="description-text">{!! Str::markdown($shortDescription) !!}</span>
<span class="description-ellipsis" style="color: var(--text-secondary);">... </span>
</div>
<div class="description-full" id="descFull" style="display: none;">
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
</div>
<button onclick="toggleDescription()" id="descToggleBtn"
style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">
Show more
</button>
@else
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
@endif
</div>
</div>
<script>
function toggleDescription() {
const descShort = document.getElementById('descShort');
const descFull = document.getElementById('descFull');
const toggleBtn = document.getElementById('descToggleBtn');
if (descShort.style.display !== 'none') {
descShort.style.display = 'none';
descFull.style.display = 'block';
toggleBtn.textContent = 'Show less';
} else {
descShort.style.display = 'block';
descFull.style.display = 'none';
toggleBtn.textContent = 'Show more';
}
}
</script>
<style>
.video-description-box .description-text {
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
}
.video-description-box .description-text p {
margin-bottom: 8px;
}
.video-description-box .description-text a {
color: #3ea6ff;
}
</style>
@endif
@include('videos.partials.description-box', ['video' => $video])
<x-video-comments :video="$video" />
</div>
<!-- Sidebar - Up Next / Recommendations -->
<div class="yt-sidebar-container">
<div class="yt-sidebar-container" style="display: flex; flex-direction: column; gap: 16px;">
@if ($playlist && $playlistVideos && $playlistVideos->count() > 0)
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">
<i class="bi bi-collection-play" style="margin-right: 8px;"></i>
@@ -596,7 +480,7 @@
@foreach ($playlistVideos as $index => $playlistVideo)
@if ($playlistVideo->id !== $video->id)
<div class="sidebar-video-card{{ $playlistVideo->id === $video->id ? ' current-video' : '' }}"
onclick="window.location.href='{{ route('videos.show', $playlistVideo->id) }}?playlist={{ $playlist->id }}'">
onclick="window.location.href='{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
@@ -638,9 +522,8 @@
@endforeach
</div>
@if ($playlist->canEdit(Auth::user()))
<a href="{{ route('playlists.show', $playlist->id) }}" class="yt-action-btn"
style="margin-top: 12px; display: inline-block;">
<i class="bi bi-pencil"></i> Edit Playlist
<a href="{{ route('playlists.show', $playlist->id) }}" class="action-btn" style="margin-top: 12px;">
<i class="bi bi-pencil"></i> <span>Edit Playlist</span>
</a>
@endif
@else
@@ -649,8 +532,8 @@
<div class="recommended-videos-list">
@foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card"
onclick="window.location.href='{{ route('videos.show', $recVideo->id) }}'">
<div class="sidebar-thumb">
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
@@ -691,113 +574,16 @@
</div>
@include('layouts.partials.share-modal')
@include('layouts.partials.edit-video-modal')
@if (Session::has('openEditModal') && Session::get('openEditModal'))
@auth
<script>
document.addEventListener('DOMContentLoaded', function() {
openEditVideoModal({{ $video->id }});
openEditVideoModal('{{ $video->getRouteKey() }}');
});
</script>
@endauth
@endif
<script>
// Playlist navigation URLs
const previousVideoUrl = @json($previousVideo ? route('videos.show', $previousVideo->id) . '?playlist=' . $playlist->id : null);
const nextVideoUrl = @json($nextVideo ? route('videos.show', $nextVideo->id) . '?playlist=' . $playlist->id : null);
// Autoplay management
let autoplayEnabled = true;
document.addEventListener('DOMContentLoaded', function() {
var videoPlayer = document.getElementById('videoPlayer');
var playlistControls = document.getElementById('playlistControls');
var autoplayToggle = document.getElementById('autoplayToggle');
if (videoPlayer) {
videoPlayer.volume = 0.5;
var playPromise = videoPlayer.play();
if (playPromise !== undefined) {
playPromise.then(function() {
console.log('Video autoplayed');
}).catch(function(error) {
console.log('Autoplay blocked');
});
}
// Handle video ended - autoplay next
if (nextVideoUrl) {
videoPlayer.addEventListener('ended', function() {
if (autoplayEnabled) {
console.log('Video ended, going to next...');
window.location.href = nextVideoUrl;
}
});
}
}
// Show playlist controls on hover
if (playlistControls) {
const videoContainer = document.getElementById('videoContainer');
videoContainer.addEventListener('mouseenter', function() {
playlistControls.classList.add('visible');
});
videoContainer.addEventListener('mouseleave', function() {
playlistControls.classList.remove('visible');
});
// Initially show for a few seconds
setTimeout(function() {
playlistControls.classList.add('visible');
}, 2000);
setTimeout(function() {
playlistControls.classList.remove('visible');
}, 6000);
}
// Autoplay toggle handler
if (autoplayToggle) {
autoplayToggle.addEventListener('change', function() {
autoplayEnabled = this.checked;
localStorage.setItem('playlistAutoplay', autoplayEnabled ? '1' : '0');
});
// Load saved preference
const savedAutoplay = localStorage.getItem('playlistAutoplay');
if (savedAutoplay !== null) {
autoplayEnabled = savedAutoplay === '1';
autoplayToggle.checked = autoplayEnabled;
}
}
// Keyboard navigation
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
goToPreviousVideo();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
goToNextVideo();
}
});
});
function goToPreviousVideo() {
if (previousVideoUrl) {
window.location.href = previousVideoUrl;
}
}
function goToNextVideo() {
if (nextVideoUrl) {
window.location.href = nextVideoUrl;
}
}
</script>
@endsection