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:
1 parent
d44490dfe0
commit
0b2e95ea65
161 files changed
+27496
-7250
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spin { display: inline-block; animation: spin .6s linear infinite; }
|
||||
</style>
|
||||
@endsection
|
||||
@@ -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> </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
|
||||
@@ -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> </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
|
||||
@@ -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
|
||||
@@ -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>© {{ date('Y') }} <strong style="color:#666;">TAKEONE</strong> — 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>
|
||||
·
|
||||
{{ 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>
|
||||
@@ -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>
|
||||
·
|
||||
@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;">🛡️</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;">
|
||||
⚠ <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;">
|
||||
⏱ 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>
|
||||
@@ -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>© {{ date('Y') }} <strong style="color:#666;">TAKEONE</strong> — 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;">🔔</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">▶ 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;">✉️</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;">
|
||||
⏱ 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 — 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>
|
||||
@@ -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;">🎬</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">▶ 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
@@ -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"
|
||||
/>
|
||||
@@ -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
|
||||
@@ -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
+2951
-761
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -146,7 +146,7 @@
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $videos->links() }}</div>
|
||||
<div class="mt-4">{{ $videos->onEachSide(1)->links() }}</div>
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
@@ -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 · 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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()) · {{ $searchShorts->count() }} short{{ $searchShorts->count() !== 1 ? 's' : '' }} @endif
|
||||
@if($searchPlaylists->count()) · {{ $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
|
||||
|
||||
@@ -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
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user