- VideoController@show now scores the newest 200 public ready videos
against the one being watched: +4 same type, +3 same language, +2
any tag overlap, +1 same channel. Sort by score desc then by newest.
Replaces the plain "latest 20 excluding self" list that was often
dominated by whatever happened to be uploaded last, with no regard
to what the viewer actually chose.
- Sidebar cards are real <a href> elements again, so middle-click,
right-click, cmd-click and screen readers all work. The SPA click
interceptors in types/{music,generic}.blade.php still fire via
data-rec-url for a soft-swap when the user left-clicks normally.
- loading="lazy" + width/height on the sidebar thumbnails so a long
Up Next list doesn't blow the critical path.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1323 lines
54 KiB
PHP
1323 lines
54 KiB
PHP
@extends('layouts.app')
|
|
|
|
@push('head')
|
|
<!-- Open Graph / WhatsApp / Facebook / Twitter / LinkedIn / Telegram Preview -->
|
|
<meta property="og:title" content="{{ $video->title }}">
|
|
<meta property="og:description"
|
|
content="{{ $video->description ? Str::limit($video->description, 200) : 'Watch ' . $video->title . ' on ' . config('app.name') }}">
|
|
<meta property="og:image" content="{{ $video->open_graph_image }}">
|
|
<meta property="og:image:width" content="{{ $video->thumbnail_width }}">
|
|
<meta property="og:image:height" content="{{ $video->thumbnail_height }}">
|
|
<meta property="og:image:alt" content="{{ $video->title }} - Video Thumbnail">
|
|
<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 property="og:locale" content="en_US">
|
|
<meta property="og:author" content="{{ $video->author_name }}">
|
|
<meta property="og:published_time" content="{{ $video->created_at->toIso8601String() }}">
|
|
|
|
<!-- Video-specific Open Graph tags -->
|
|
<meta property="video:duration" content="{{ $video->duration }}">
|
|
<meta property="video:release_date" content="{{ $video->created_at->toIso8601String() }}">
|
|
|
|
<!-- Alternative video tag for some platforms -->
|
|
<meta property="og:video" content="{{ $video->stream_url }}">
|
|
<meta property="og:video:url" content="{{ $video->stream_url }}">
|
|
<meta property="og:video:secure_url" content="{{ $video->stream_url }}">
|
|
<meta property="og:video:type" content="video/mp4">
|
|
<meta property="og:video:width" content="{{ $video->width ?? 1920 }}">
|
|
<meta property="og:video:height" content="{{ $video->height ?? 1080 }}">
|
|
|
|
<!-- Twitter Card - Enhanced for video sharing -->
|
|
<meta name="twitter:card" content="summary_large_image">
|
|
<meta name="twitter:site" content="{{ config('app.name') }}">
|
|
<meta name="twitter:creator" content="{{ $video->author_name }}">
|
|
<meta name="twitter:title" content="{{ $video->title }}">
|
|
<meta name="twitter:description"
|
|
content="{{ $video->description ? Str::limit($video->description, 200) : 'Watch ' . $video->title . ' on ' . config('app.name') }}">
|
|
<meta name="twitter:image" content="{{ $video->open_graph_image }}">
|
|
<meta name="twitter:image:alt" content="{{ $video->title }} - Video Thumbnail">
|
|
|
|
<!-- Twitter Player card for video -->
|
|
<meta name="twitter:player" content="{{ $video->share_url }}">
|
|
<meta name="twitter:player:width" content="{{ $video->width ?? 1920 }}">
|
|
<meta name="twitter:player:height" content="{{ $video->height ?? 1080 }}">
|
|
<meta name="twitter:player:stream" content="{{ $video->stream_url }}">
|
|
|
|
<!-- LinkedIn specific -->
|
|
<meta property="linkedin:owner" content="{{ $video->author_name }}">
|
|
|
|
<!-- Pinterest -->
|
|
<meta name="pinterest-rich-pin" content="true">
|
|
|
|
<!-- WhatsApp specific (uses Open Graph) -->
|
|
<!-- No additional meta needed - uses og: tags above -->
|
|
|
|
<!-- Schema.org VideoObject for search engines -->
|
|
<script type="application/ld+json">
|
|
{
|
|
"@context": "https://schema.org",
|
|
"@type": "VideoObject",
|
|
"name": "{{ $video->title }}",
|
|
"description": "{{ $video->description ? addslashes($video->description) : 'Watch ' . addslashes($video->title) . ' on ' . config('app.name') }}",
|
|
"thumbnailUrl": "{{ $video->open_graph_image }}",
|
|
"uploadDate": "{{ $video->created_at->toIso8601String() }}",
|
|
"duration": "{{ $video->iso_duration }}",
|
|
"contentUrl": "{{ $video->stream_url }}",
|
|
"embedUrl": "{{ $video->share_url }}",
|
|
"author": {
|
|
"@type": "Person",
|
|
"name": "{{ $video->author_name }}"
|
|
},
|
|
"publisher": {
|
|
"@type": "Organization",
|
|
"name": "{{ config('app.name') }}",
|
|
"logo": {
|
|
"@type": "ImageObject",
|
|
"url": "{{ asset('storage/images/fullLogo.png') }}"
|
|
}
|
|
},
|
|
"interactionStatistic": [
|
|
{
|
|
"@type": "InteractionCounter",
|
|
"interactionType": "https://schema.org/WatchAction",
|
|
"userInteractionCount": {{ $video->view_count }}
|
|
},
|
|
{
|
|
"@type": "InteractionCounter",
|
|
"interactionType": "https://schema.org/LikeAction",
|
|
"userInteractionCount": {{ $video->like_count }}
|
|
}
|
|
]
|
|
}
|
|
</script>
|
|
@endpush
|
|
|
|
@section('title', $video->title . ' | ' . config('app.name'))
|
|
|
|
@section('extra_styles')
|
|
<style>
|
|
/* Video Section */
|
|
.yt-video-section {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
/* Video Player */
|
|
.video-container {
|
|
position: relative;
|
|
aspect-ratio: 16/9;
|
|
background: #000;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
max-height: 70vh;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
width: 100%;
|
|
}
|
|
|
|
.video-container.portrait,
|
|
.video-container.square,
|
|
.video-container.ultrawide {
|
|
margin: 0 auto;
|
|
width: auto;
|
|
}
|
|
|
|
.video-container.portrait {
|
|
aspect-ratio: 9/16;
|
|
max-width: 50vh;
|
|
}
|
|
|
|
.video-container.square {
|
|
aspect-ratio: 1/1;
|
|
max-width: 70vh;
|
|
}
|
|
|
|
.video-container.ultrawide {
|
|
aspect-ratio: 21/9;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.video-container video {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: contain;
|
|
}
|
|
|
|
/* Video Info */
|
|
.video-title {
|
|
font-size: 20px;
|
|
font-weight: 500;
|
|
margin: 16px 0 8px;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.video-stats-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding-bottom: 12px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
flex-wrap: wrap;
|
|
gap: 12px;
|
|
}
|
|
|
|
.video-stats-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.video-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
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;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 16px 0;
|
|
}
|
|
|
|
.channel-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.channel-avatar {
|
|
width: 48px;
|
|
height: 48px;
|
|
border-radius: 50%;
|
|
background: #555;
|
|
}
|
|
|
|
.channel-name {
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.channel-subs {
|
|
font-size: 14px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.subscribe-btn {
|
|
background: white;
|
|
color: black;
|
|
border: none;
|
|
padding: 8px 16px;
|
|
border-radius: 18px;
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* Description */
|
|
.video-description {
|
|
background: var(--bg-secondary);
|
|
border-radius: 12px;
|
|
padding: 16px;
|
|
margin-top: 16px;
|
|
}
|
|
|
|
.description-text {
|
|
white-space: pre-wrap;
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
/* Sidebar */
|
|
.yt-sidebar-container {
|
|
width: 400px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.sidebar-video-card {
|
|
display: flex;
|
|
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 {
|
|
width: 168px;
|
|
aspect-ratio: 16/9;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
background: #1a1a1a;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.sidebar-thumb img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.sidebar-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.sidebar-title {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
margin-bottom: 4px;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.sidebar-meta {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
/* Playlist controls bar */
|
|
.pl-controls-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
margin-bottom: 10px;
|
|
padding: 6px 8px;
|
|
background: var(--bg-secondary);
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
.pl-ctrl-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
padding: 5px 8px;
|
|
border-radius: 6px;
|
|
font-size: 15px;
|
|
transition: color .15s, background .15s;
|
|
white-space: nowrap;
|
|
}
|
|
.pl-ctrl-btn:hover:not(:disabled) {
|
|
color: var(--text-primary);
|
|
background: rgba(255,255,255,.07);
|
|
}
|
|
.pl-ctrl-btn:disabled {
|
|
opacity: .35;
|
|
cursor: default;
|
|
}
|
|
.pl-ctrl-btn.pl-ctrl-active {
|
|
color: var(--brand-red);
|
|
}
|
|
.pl-ctrl-divider {
|
|
width: 1px;
|
|
height: 18px;
|
|
background: var(--border-color);
|
|
margin: 0 2px;
|
|
flex-shrink: 0;
|
|
}
|
|
.pl-ctrl-autoplay {
|
|
margin-left: auto;
|
|
font-size: 13px;
|
|
}
|
|
.pl-autoplay-label {
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 1300px) {
|
|
.yt-sidebar-container {
|
|
width: 300px;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 991px) {
|
|
.yt-main {
|
|
margin-left: 0;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.yt-sidebar-container {
|
|
width: 100%;
|
|
}
|
|
|
|
|
|
.sidebar-video-card {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.sidebar-thumb {
|
|
width: 100%;
|
|
}
|
|
|
|
/* Video Layout Container - Stack vertically on tablet/mobile */
|
|
.video-layout-container {
|
|
flex-direction: column !important;
|
|
}
|
|
|
|
.yt-video-section {
|
|
width: 100% !important;
|
|
flex: none !important;
|
|
}
|
|
|
|
.yt-sidebar-container {
|
|
width: 100% !important;
|
|
margin-top: 16px;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 576px) {
|
|
.video-stats-row {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.video-actions {
|
|
width: 100%;
|
|
overflow-x: auto;
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.yt-main {
|
|
padding: 0 !important;
|
|
max-width: 100% !important;
|
|
margin: 0 !important;
|
|
}
|
|
|
|
/* Mobile video player fixes - always full width */
|
|
.video-layout-container {
|
|
max-width: 100% !important;
|
|
margin: 0 !important;
|
|
padding: 0 !important;
|
|
width: 100% !important;
|
|
}
|
|
|
|
.yt-video-section {
|
|
width: 100% !important;
|
|
max-width: 100% !important;
|
|
padding: 0 !important;
|
|
margin: 0 !important;
|
|
}
|
|
|
|
.video-container {
|
|
width: 100% !important;
|
|
max-width: 100% !important;
|
|
max-height: 50vh !important;
|
|
margin: 0 !important;
|
|
padding: 0 !important;
|
|
border-radius: 0 !important;
|
|
}
|
|
|
|
.video-container video {
|
|
width: 100% !important;
|
|
max-width: 100% !important;
|
|
object-fit: contain !important;
|
|
}
|
|
|
|
.video-title {
|
|
font-size: 16px !important;
|
|
margin: 12px 0 6px !important;
|
|
}
|
|
|
|
.channel-row {
|
|
flex-direction: column;
|
|
align-items: flex-start !important;
|
|
gap: 12px;
|
|
}
|
|
|
|
.channel-info {
|
|
width: 100%;
|
|
}
|
|
|
|
.subscribe-btn {
|
|
width: 100%;
|
|
}
|
|
|
|
.video-description {
|
|
padding: 12px !important;
|
|
}
|
|
}
|
|
</style>
|
|
@endsection
|
|
|
|
@section('content')
|
|
|
|
<!-- Video Layout Container -->
|
|
<div class="video-layout-container" style="display: flex; gap: 24px; max-width: 1800px; margin: 0 auto;">
|
|
|
|
<!-- 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) }}" type="video/mp4">
|
|
</video>
|
|
</div>
|
|
|
|
@php
|
|
$typeIcon = match ($video->type) {
|
|
'music' => 'bi-music-note',
|
|
'match' => 'bi-trophy',
|
|
default => 'bi-film',
|
|
};
|
|
@endphp
|
|
|
|
<!-- Video Title with Type Icon -->
|
|
<h1 class="video-title" style="display: flex; align-items: center; gap: 10px;">
|
|
<i class="bi {{ $typeIcon }}" style="color: #ef4444;"></i>
|
|
<span>{{ $video->title }}</span>
|
|
</h1>
|
|
|
|
<!-- Stats Row -->
|
|
<div class="video-stats-row">
|
|
<div class="video-stats-left">
|
|
<span>{{ number_format($video->view_count) }} views</span>
|
|
<span>•</span>
|
|
<span>{{ $video->created_at->format('M d, Y') }}</span>
|
|
</div>
|
|
<div class="video-actions">
|
|
@auth
|
|
<!-- Like Button -->
|
|
<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' : '' }}">
|
|
<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>
|
|
|
|
<!-- Edit / Delete Buttons - Only for video owner -->
|
|
@if (Auth::id() === $video->user_id)
|
|
<button class="yt-action-btn" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
|
|
<i class="bi bi-pencil"></i> Edit
|
|
</button>
|
|
<button class="yt-action-btn" onclick="openVideoDeleteDialog()"
|
|
style="color:#ef4444;">
|
|
<i class="bi bi-trash"></i> Delete
|
|
</button>
|
|
@endif
|
|
@else
|
|
<a href="{{ route('login') }}" class="yt-action-btn">
|
|
<i class="bi bi-hand-thumbs-up"></i> Like
|
|
</a>
|
|
@endauth
|
|
@if ($video->isShareable())
|
|
<button class="yt-action-btn"
|
|
onclick="videoShare('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')"><i
|
|
class="bi bi-share"></i> Share</button>
|
|
@endif
|
|
|
|
<!-- Save to Playlist Button -->
|
|
<x-save-to-playlist-button :video="$video" class="yt-action-btn">
|
|
<i class="bi bi-collection-plus"></i> Save
|
|
</x-save-to-playlist-button>
|
|
@auth
|
|
<!-- Quick Watch Later Button -->
|
|
<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">
|
|
<i class="bi bi-clock"></i>
|
|
</button>
|
|
</form>
|
|
@endauth
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Channel Row -->
|
|
<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="{{ $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
|
|
<div class="channel-avatar" style="width: 36px; height: 36px;"></div>
|
|
@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>
|
|
</a>
|
|
|
|
{{-- Subscribe Button --}}
|
|
@auth
|
|
@if (Auth::id() !== $video->user_id)
|
|
<button class="subscribe-btn">Subscribe</button>
|
|
@endif
|
|
@else
|
|
<a href="{{ route('login') }}" class="subscribe-btn">Subscribe</a>
|
|
@endauth
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ── 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
|
|
|
|
<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 { display: flex; align-items: center; justify-content: center; gap: 6px; margin: 12px auto 0; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); font-weight: 700; font-size: 13px; cursor: pointer; padding: 7px 16px; border-radius: 18px; transition: background .15s ease, border-color .15s ease; }
|
|
.vdb-show-more:hover { background: var(--bg-hover, rgba(127,127,127,.12)); }
|
|
.vdb-show-more i { font-size: 14px; transition: transform .2s ease; }
|
|
.vdb-show-more.expanded i { transform: rotate(180deg); }
|
|
|
|
</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>
|
|
|
|
{{-- 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)"><span>Show more</span> <i class="bi bi-chevron-down"></i></button>
|
|
@endif
|
|
@else
|
|
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- Insights panel (owner only) --}}
|
|
@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();
|
|
}
|
|
|
|
// ── Description expand/collapse ───────────────────────
|
|
function toggleVdbDesc(btn) {
|
|
const short = document.getElementById('vdbDescShort');
|
|
const full = document.getElementById('vdbDescFull');
|
|
const label = btn.querySelector('span');
|
|
if (full.style.display === 'none') {
|
|
short.style.display = 'none';
|
|
full.style.display = 'block';
|
|
label.textContent = 'Show less';
|
|
btn.classList.add('expanded');
|
|
} else {
|
|
short.style.display = 'block';
|
|
full.style.display = 'none';
|
|
label.textContent = 'Show more';
|
|
btn.classList.remove('expanded');
|
|
}
|
|
}
|
|
|
|
// ── Share + track ──────────────────────────────────────
|
|
function videoShare(url, title, trackUrl) {
|
|
// Pass the full argument set so email + share-to-members are available (see share component rule).
|
|
openShareModal(url, title, '', '{{ auth()->check() ? route('videos.shareEmail', $video) : '' }}', '{{ auth()->check() ? route('videos.shareMembers', $video) : '' }}');
|
|
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])
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sidebar - Up Next / Recommendations -->
|
|
<div class="yt-sidebar-container">
|
|
@if ($playlist && $playlistVideos && $playlistVideos->count() > 0)
|
|
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 8px;">
|
|
<i class="bi bi-collection-play" style="margin-right: 8px;"></i>
|
|
{{ $playlist->name }}
|
|
<span
|
|
style="font-weight: 400; color: var(--text-secondary); font-size: 14px;">({{ $playlistVideos->count() }}
|
|
videos)</span>
|
|
</h3>
|
|
|
|
{{-- Playlist playback controls --}}
|
|
<div class="pl-controls-bar">
|
|
<button class="pl-ctrl-btn" id="plPrevBtn" title="Previous"
|
|
@if(!$previousVideo) disabled @endif
|
|
onclick="plGoTo('{{ $previousVideo ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : '' }}')">
|
|
<i class="bi bi-skip-start-fill"></i>
|
|
</button>
|
|
<button class="pl-ctrl-btn" id="plNextBtn" title="Next"
|
|
onclick="plNext()">
|
|
<i class="bi bi-skip-end-fill"></i>
|
|
</button>
|
|
<div class="pl-ctrl-divider"></div>
|
|
<button class="pl-ctrl-btn" id="plShuffleBtn" title="Shuffle" onclick="plToggleShuffle()">
|
|
<i class="bi bi-shuffle"></i>
|
|
</button>
|
|
<button class="pl-ctrl-btn" id="plLoopBtn" title="Loop" onclick="plToggleLoop()">
|
|
<i class="bi bi-repeat"></i>
|
|
</button>
|
|
<button class="pl-ctrl-btn pl-ctrl-autoplay" id="plAutoplayBtn" title="Autoplay" onclick="plToggleAutoplay()">
|
|
<i class="bi bi-play-circle"></i>
|
|
<span class="pl-autoplay-label">Autoplay</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="recommended-videos-list" id="plVideoList">
|
|
@foreach ($playlistVideos as $index => $playlistVideo)
|
|
@php $isCurrent = $playlistVideo->id === $video->id; @endphp
|
|
<div class="sidebar-video-card{{ $isCurrent ? ' current-video' : '' }}"
|
|
data-pl-id="{{ $playlistVideo->id }}"
|
|
{{ $isCurrent ? '' : 'onclick="plGoTo(\''.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="{{ route('media.thumbnail', $playlistVideo->thumbnail) }}"
|
|
alt="{{ $playlistVideo->title }}">
|
|
@else
|
|
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
|
|
alt="{{ $playlistVideo->title }}">
|
|
@endif
|
|
@if ($playlistVideo->duration)
|
|
<span class="yt-video-duration">{{ gmdate('i:s', $playlistVideo->duration) }}</span>
|
|
@endif
|
|
@if ($playlistVideo->is_shorts)
|
|
<span class="yt-shorts-badge"
|
|
style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
|
|
<i class="bi bi-collection-play-fill"></i> SHORTS
|
|
</span>
|
|
@endif
|
|
<!-- Playlist position indicator -->
|
|
<span
|
|
style="position: absolute; bottom: 4px; left: 4px; background: rgba(0,0,0,0.8); color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: 500;">
|
|
{{ $index + 1 }}
|
|
</span>
|
|
</div>
|
|
<div class="sidebar-info">
|
|
<div class="sidebar-title">
|
|
<i class="bi {{ match ($playlistVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
|
|
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
|
|
{{ Str::limit($playlistVideo->title, 60) }}
|
|
</div>
|
|
<div class="sidebar-meta">
|
|
@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>
|
|
@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>
|
|
@endif
|
|
@else
|
|
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
|
|
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
|
<div class="recommended-videos-list">
|
|
@foreach ($recommendedVideos as $recVideo)
|
|
{{-- Real <a href> cards so right-click / open-in-new-tab /
|
|
middle-click and screen readers all work. The SPA
|
|
transition hooks in types/*.blade.php still intercept
|
|
these clicks via data-rec-url for a soft load. --}}
|
|
<a class="sidebar-video-card"
|
|
href="{{ route('videos.show', $recVideo) }}"
|
|
data-rec-url="{{ route('videos.show', $recVideo) }}">
|
|
<div class="sidebar-thumb" style="position: relative;">
|
|
@if ($recVideo->thumbnail)
|
|
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
|
|
alt="{{ $recVideo->title }}" width="168" height="94"
|
|
loading="lazy" decoding="async">
|
|
@else
|
|
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
|
|
alt="{{ $recVideo->title }}" width="168" height="94"
|
|
loading="lazy" decoding="async">
|
|
@endif
|
|
@if ($recVideo->duration)
|
|
<span class="yt-video-duration">{{ gmdate('i:s', $recVideo->duration) }}</span>
|
|
@endif
|
|
@if ($recVideo->is_shorts)
|
|
<span class="yt-shorts-badge"
|
|
style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
|
|
<i class="bi bi-collection-play-fill" aria-hidden="true"></i> SHORTS
|
|
</span>
|
|
@endif
|
|
</div>
|
|
<div class="sidebar-info">
|
|
<div class="sidebar-title">
|
|
<i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
|
|
style="color: #ef4444; margin-right: 4px; font-size: 12px;"
|
|
aria-hidden="true"></i>
|
|
{{ Str::limit($recVideo->title, 60) }}
|
|
</div>
|
|
<div class="sidebar-meta">
|
|
<div>{{ $recVideo->user->name ?? 'Unknown' }}</div>
|
|
<div>{{ number_format($recVideo->view_count) }} views •
|
|
{{ $recVideo->created_at->diffForHumans() }}</div>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
@endforeach
|
|
</div>
|
|
@else
|
|
<div class="text-secondary">No recommendations available yet. Check back later!</div>
|
|
@endif
|
|
@endif
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
<!-- Mobile Bottom Action Bar -->
|
|
<div class="mobile-bottom-bar">
|
|
@auth
|
|
<form method="POST"
|
|
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' : '' }}"
|
|
style="width:100%;">
|
|
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
|
|
<span>{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}</span>
|
|
</button>
|
|
</form>
|
|
@else
|
|
<a href="{{ route('login') }}" class="yt-action-btn" style="flex:1;text-align:center;">
|
|
<i class="bi bi-hand-thumbs-up"></i><span>Like</span>
|
|
</a>
|
|
@endauth
|
|
|
|
@if ($video->isShareable())
|
|
<x-share-button :video="$video" class="yt-action-btn" style="flex:1;">
|
|
<i class="bi bi-share"></i><span>Share</span>
|
|
</x-share-button>
|
|
@endif
|
|
|
|
<x-save-to-playlist-button :video="$video" class="yt-action-btn" style="flex:1;">
|
|
<i class="bi bi-collection-plus"></i><span>Save</span>
|
|
</x-save-to-playlist-button>
|
|
|
|
@auth
|
|
@if (Auth::id() === $video->user_id)
|
|
<button class="yt-action-btn" onclick="openVideoDeleteDialog()"
|
|
style="flex:1;color:#ef4444;">
|
|
<i class="bi bi-trash"></i><span>Delete</span>
|
|
</button>
|
|
@else
|
|
<button class="yt-action-btn" style="flex:1;background:var(--brand-red);color:white;">
|
|
<i class="bi bi-bell"></i><span>Subscribe</span>
|
|
</button>
|
|
@endif
|
|
@else
|
|
<a href="{{ route('login') }}" class="yt-action-btn"
|
|
style="flex:1;background:var(--brand-red);color:white;text-align:center;">
|
|
<i class="bi bi-bell"></i><span>Subscribe</span>
|
|
</a>
|
|
@endauth
|
|
</div>
|
|
|
|
{{-- Share modal is the app-layout singleton (<x-share-modal/>); do not re-render it here. --}}
|
|
@include('layouts.partials.edit-video-modal')
|
|
@include('layouts.partials.add-to-playlist-modal')
|
|
|
|
@if (Session::has('openEditModal') && Session::get('openEditModal'))
|
|
@auth
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
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>
|
|
@if($playlist && $playlistVideos && $playlistVideos->count() > 0)
|
|
// ── Playlist data ────────────────────────────────────────────────────────
|
|
var PL_ID = '{{ $playlist->share_token }}';
|
|
var PL_TOKEN = '{{ $playlist->share_token }}';
|
|
var PL_CURRENT = {{ $video->id }};
|
|
var PL_NEXT_URL = '{{ $nextVideo ? route("videos.show", $nextVideo)."?playlist=".$playlist->share_token : "" }}';
|
|
var PL_PREV_URL = '{{ $previousVideo ? route("videos.show", $previousVideo)."?playlist=".$playlist->share_token : "" }}';
|
|
var PL_FIRST_URL = '{{ $playlistVideos->count() ? route("videos.show", $playlistVideos->first())."?playlist=".$playlist->share_token : "" }}';
|
|
var PL_VIDEOS = {!! $playlistVideos->map(fn($v) => [
|
|
'id' => $v->id,
|
|
'url' => route('videos.show', $v).'?playlist='.$playlist->share_token,
|
|
])->values()->toJson() !!};
|
|
|
|
// ── localStorage helpers ─────────────────────────────────────────────────
|
|
function plGet(key, def) {
|
|
var v = localStorage.getItem('pl_' + key + '_' + PL_ID);
|
|
return v !== null ? v : def;
|
|
}
|
|
function plSet(key, val) { localStorage.setItem('pl_' + key + '_' + PL_ID, val); }
|
|
|
|
// ── State (persisted per playlist) ───────────────────────────────────────
|
|
// autoplay: '1' | '0' (default on)
|
|
// loop: 'off' | 'all' | 'one'
|
|
// shuffle: '1' | '0'
|
|
var plAutoplay = plGet('autoplay', '1');
|
|
var plLoop = plGet('loop', 'off');
|
|
var plShuffle = plGet('shuffle', '0');
|
|
|
|
// Shuffled order: array of indices into PL_VIDEOS
|
|
function plGetShuffleOrder() {
|
|
var raw = localStorage.getItem('pl_shuffleOrder_' + PL_ID);
|
|
if (raw) { try { return JSON.parse(raw); } catch(e){} }
|
|
return null;
|
|
}
|
|
function plBuildShuffleOrder() {
|
|
var order = PL_VIDEOS.map(function(_,i){ return i; });
|
|
for (var i = order.length - 1; i > 0; i--) {
|
|
var j = Math.floor(Math.random() * (i + 1));
|
|
var t = order[i]; order[i] = order[j]; order[j] = t;
|
|
}
|
|
localStorage.setItem('pl_shuffleOrder_' + PL_ID, JSON.stringify(order));
|
|
return order;
|
|
}
|
|
|
|
// ── Navigation ───────────────────────────────────────────────────────────
|
|
function plGoTo(url) { if (url) window.location.href = url; }
|
|
|
|
function plNext() {
|
|
if (plShuffle === '1') {
|
|
var order = plGetShuffleOrder() || plBuildShuffleOrder();
|
|
var curIdx = PL_VIDEOS.findIndex(function(v){ return v.id === PL_CURRENT; });
|
|
var pos = order.indexOf(curIdx);
|
|
var nextPos = (pos + 1) % order.length;
|
|
if (pos === order.length - 1 && plLoop !== 'all') return; // end of shuffle, no loop
|
|
plGoTo(PL_VIDEOS[order[nextPos]].url);
|
|
} else {
|
|
if (PL_NEXT_URL) {
|
|
plGoTo(PL_NEXT_URL);
|
|
} else if (plLoop === 'all' && PL_FIRST_URL) {
|
|
plGoTo(PL_FIRST_URL);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Button UI ────────────────────────────────────────────────────────────
|
|
function plRenderControls() {
|
|
var shuffleBtn = document.getElementById('plShuffleBtn');
|
|
var loopBtn = document.getElementById('plLoopBtn');
|
|
var autoplayBtn = document.getElementById('plAutoplayBtn');
|
|
var nextBtn = document.getElementById('plNextBtn');
|
|
var prevBtn = document.getElementById('plPrevBtn');
|
|
|
|
// Shuffle
|
|
if (shuffleBtn) {
|
|
shuffleBtn.classList.toggle('pl-ctrl-active', plShuffle === '1');
|
|
shuffleBtn.title = plShuffle === '1' ? 'Shuffle: On' : 'Shuffle: Off';
|
|
}
|
|
|
|
// Loop
|
|
if (loopBtn) {
|
|
loopBtn.classList.remove('pl-ctrl-active');
|
|
if (plLoop === 'all') {
|
|
loopBtn.classList.add('pl-ctrl-active');
|
|
loopBtn.innerHTML = '<i class="bi bi-repeat"></i>';
|
|
loopBtn.title = 'Loop: All';
|
|
} else if (plLoop === 'one') {
|
|
loopBtn.classList.add('pl-ctrl-active');
|
|
loopBtn.innerHTML = '<i class="bi bi-repeat-1"></i>';
|
|
loopBtn.title = 'Loop: One';
|
|
} else {
|
|
loopBtn.innerHTML = '<i class="bi bi-repeat"></i>';
|
|
loopBtn.title = 'Loop: Off';
|
|
}
|
|
}
|
|
|
|
// Autoplay
|
|
if (autoplayBtn) {
|
|
autoplayBtn.classList.toggle('pl-ctrl-active', plAutoplay === '1');
|
|
autoplayBtn.title = plAutoplay === '1' ? 'Autoplay: On' : 'Autoplay: Off';
|
|
}
|
|
|
|
// Next/Prev availability
|
|
if (nextBtn) nextBtn.disabled = !PL_NEXT_URL && plLoop !== 'all' && plShuffle !== '1';
|
|
if (prevBtn) prevBtn.disabled = !PL_PREV_URL;
|
|
}
|
|
|
|
// ── Toggle handlers ──────────────────────────────────────────────────────
|
|
function plToggleShuffle() {
|
|
plShuffle = plShuffle === '1' ? '0' : '1';
|
|
plSet('shuffle', plShuffle);
|
|
if (plShuffle === '1') plBuildShuffleOrder();
|
|
else localStorage.removeItem('pl_shuffleOrder_' + PL_ID);
|
|
plRenderControls();
|
|
}
|
|
|
|
function plToggleLoop() {
|
|
var states = ['off', 'all', 'one'];
|
|
var idx = states.indexOf(plLoop);
|
|
plLoop = states[(idx + 1) % states.length];
|
|
plSet('loop', plLoop);
|
|
plRenderControls();
|
|
// Apply loop-one directly on the video element
|
|
var vp = document.getElementById('videoPlayer');
|
|
if (vp) vp.loop = (plLoop === 'one');
|
|
}
|
|
|
|
function plToggleAutoplay() {
|
|
plAutoplay = plAutoplay === '1' ? '0' : '1';
|
|
plSet('autoplay', plAutoplay);
|
|
plRenderControls();
|
|
}
|
|
|
|
@endif
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
var videoPlayer = document.getElementById('videoPlayer');
|
|
if (videoPlayer) {
|
|
videoPlayer.volume = 0.5;
|
|
var playPromise = videoPlayer.play();
|
|
if (playPromise !== undefined) {
|
|
playPromise.then(function() {}).catch(function() {});
|
|
}
|
|
|
|
@if($playlist && $playlistVideos && $playlistVideos->count() > 0)
|
|
// Apply loop-one state immediately on load
|
|
if (plLoop === 'one') videoPlayer.loop = true;
|
|
|
|
videoPlayer.addEventListener('ended', function() {
|
|
if (plLoop === 'one') return; // native loop handles it
|
|
|
|
if (plAutoplay === '1') {
|
|
plNext();
|
|
}
|
|
// If autoplay off, do nothing — video just stops
|
|
});
|
|
|
|
// Render control states
|
|
plRenderControls();
|
|
@endif
|
|
}
|
|
|
|
// Scroll current video into view in sidebar
|
|
var currentCard = document.querySelector('.sidebar-video-card.current-video');
|
|
if (currentCard) {
|
|
setTimeout(function() {
|
|
currentCard.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
}, 400);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
@auth
|
|
@if(Auth::id() === $video->user_id)
|
|
@php $owner2fa = Auth::user()->two_factor_enabled && Auth::user()->two_factor_secret; @endphp
|
|
|
|
{{-- Delete confirmation dialog --}}
|
|
<div id="videoDeleteDialog" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.7);backdrop-filter:blur(4px);align-items:center;justify-content:center;padding:16px;" onclick="if(event.target===this)closeVideoDeleteDialog()">
|
|
<div style="background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:16px;width:100%;max-width:420px;overflow:hidden;box-shadow:0 24px 60px rgba(0,0,0,.6);">
|
|
<div style="padding:20px 24px 16px;border-bottom:1px solid var(--border-color);display:flex;align-items:center;justify-content:space-between;">
|
|
<div style="display:flex;align-items:center;gap:10px;font-size:16px;font-weight:600;color:#ef4444;">
|
|
<i class="bi bi-trash"></i> Delete Video
|
|
</div>
|
|
<button onclick="closeVideoDeleteDialog()" style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:18px;line-height:1;padding:4px;"><i class="bi bi-x-lg"></i></button>
|
|
</div>
|
|
<div style="padding:20px 24px;">
|
|
<p style="margin:0 0 12px;font-size:14px;color:var(--text-primary);">
|
|
You are about to permanently delete <strong>{{ $video->title }}</strong>.
|
|
</p>
|
|
<div style="background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.25);border-radius:8px;padding:10px 14px;font-size:13px;color:#fca5a5;display:flex;gap:8px;align-items:flex-start;">
|
|
<i class="bi bi-exclamation-triangle-fill" style="flex-shrink:0;margin-top:1px;"></i>
|
|
<span>All views, likes, comments, and HLS files will also be deleted. This cannot be undone.</span>
|
|
</div>
|
|
@if($owner2fa)
|
|
<div style="margin-top:16px;">
|
|
<label style="font-size:13px;color:var(--text-secondary);display:flex;align-items:center;gap:6px;margin-bottom:8px;">
|
|
<i class="bi bi-shield-lock-fill" style="color:#a78bfa;"></i>
|
|
Enter your <strong style="color:var(--text-primary);">2FA code</strong> to confirm
|
|
</label>
|
|
<input type="text" id="delVideoOtp" inputmode="numeric" pattern="[0-9]*"
|
|
maxlength="6" autocomplete="one-time-code" placeholder="000000"
|
|
style="width:100%;background:var(--bg-primary);border:1px solid var(--border-color);border-radius:8px;padding:10px 14px;color:#fff;font-size:22px;letter-spacing:.3em;text-align:center;box-sizing:border-box;">
|
|
</div>
|
|
@endif
|
|
<div id="delVideoError" style="display:none;margin-top:12px;padding:8px 12px;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.3);border-radius:6px;color:#f87171;font-size:13px;"></div>
|
|
</div>
|
|
<div style="padding:12px 24px 20px;display:flex;gap:10px;justify-content:flex-end;">
|
|
<button onclick="closeVideoDeleteDialog()" class="yt-action-btn">Cancel</button>
|
|
<button id="delVideoConfirmBtn" onclick="confirmVideoDelete()" class="yt-action-btn" style="background:#ef4444;color:#fff;border-color:#ef4444;">
|
|
<i class="bi bi-trash"></i> Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const _owner2fa = {{ $owner2fa ? 'true' : 'false' }};
|
|
const _videoDeleteUrl = '{{ route('videos.destroy', $video) }}';
|
|
const _videoCsrf = '{{ csrf_token() }}';
|
|
|
|
function openVideoDeleteDialog() {
|
|
const dlg = document.getElementById('videoDeleteDialog');
|
|
dlg.style.display = 'flex';
|
|
dlg.style.alignItems = 'center';
|
|
dlg.style.justifyContent = 'center';
|
|
document.getElementById('delVideoError').style.display = 'none';
|
|
if (_owner2fa) { document.getElementById('delVideoOtp').value = ''; setTimeout(() => document.getElementById('delVideoOtp').focus(), 100); }
|
|
}
|
|
function closeVideoDeleteDialog() {
|
|
document.getElementById('videoDeleteDialog').style.display = 'none';
|
|
}
|
|
function confirmVideoDelete() {
|
|
const btn = document.getElementById('delVideoConfirmBtn');
|
|
const errEl = document.getElementById('delVideoError');
|
|
const otpCode = _owner2fa ? document.getElementById('delVideoOtp').value.replace(/\s/g,'') : '';
|
|
|
|
if (_owner2fa && otpCode.length !== 6) {
|
|
errEl.textContent = 'Please enter your 6-digit 2FA code.';
|
|
errEl.style.display = 'block';
|
|
document.getElementById('delVideoOtp').focus();
|
|
return;
|
|
}
|
|
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Deleting…';
|
|
errEl.style.display = 'none';
|
|
|
|
const body = new URLSearchParams({ '_token': _videoCsrf, '_method': 'DELETE' });
|
|
if (_owner2fa) body.append('otp_code', otpCode);
|
|
|
|
fetch(_videoDeleteUrl, {
|
|
method: 'POST',
|
|
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: body.toString()
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
window.location.href = '/';
|
|
} else {
|
|
errEl.textContent = data.message || 'Delete failed.';
|
|
errEl.style.display = 'block';
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="bi bi-trash"></i> Delete';
|
|
if (_owner2fa) { document.getElementById('delVideoOtp').value = ''; document.getElementById('delVideoOtp').focus(); }
|
|
}
|
|
})
|
|
.catch(() => {
|
|
errEl.textContent = 'An error occurred. Please try again.';
|
|
errEl.style.display = 'block';
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="bi bi-trash"></i> Delete';
|
|
});
|
|
}
|
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeVideoDeleteDialog(); });
|
|
</script>
|
|
@endif
|
|
@endauth
|
|
@endsection
|
|
|
|
|
|
/* Mobile Bottom Action Bar */
|
|
@media (max-width: 576px) {
|
|
.mobile-bottom-bar {
|
|
display: flex !important;
|
|
}
|
|
.desktop-actions {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
.mobile-bottom-bar {
|
|
display: none;
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
background: var(--bg-primary);
|
|
border-top: 1px solid var(--border-color);
|
|
padding: 12px 16px;
|
|
justify-content: space-around;
|
|
z-index: 1000;
|
|
gap: 8px;
|
|
box-shadow: 0 -2px 10px rgba(0,0,0,0.3);
|
|
}
|
|
|
|
.mobile-bottom-bar .yt-action-btn {
|
|
flex: 1;
|
|
justify-content: center;
|
|
padding: 12px 8px;
|
|
font-size: 12px;
|
|
min-height: 44px;
|
|
}
|
|
|
|
.mobile-bottom-bar .yt-action-btn i {
|
|
font-size: 18px;
|
|
}
|
|
|
|
@media (max-width: 576px) {
|
|
.yt-video-section {
|
|
padding-bottom: 70px;
|
|
}
|
|
}
|
|
|
|
|
|
<!-- Extra Mobile Styles -->
|
|
<style>
|
|
@media (max-width: 400px) {
|
|
.video-layout-container {
|
|
padding: 0 !important;
|
|
}
|
|
|
|
.video-title {
|
|
font-size: 15px !important;
|
|
padding: 0 4px;
|
|
}
|
|
|
|
.video-stats-left {
|
|
font-size: 12px;
|
|
}
|
|
|
|
.video-description-box {
|
|
margin: 12px 4px;
|
|
padding: 10px !important;
|
|
}
|
|
|
|
.comments-section {
|
|
margin-top: 16px;
|
|
padding: 12px 4px;
|
|
}
|
|
|
|
.comment-form {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.comment-form>img {
|
|
display: none;
|
|
}
|
|
}
|
|
</style>
|