ghassan 665bad76bf Match cards: composed title, header stage from match title, equal-sized grid
- SportsMatchController syncs Video.title with SportsMatch.title on
  store/update so the "Match title" field drives the video's display
  title everywhere.
- headerData(): "championship" now prefers event_name (event title)
  and adds a dedicated "stage" key sourced from SportsMatch.title
  (shown between the yellow lines above the weight class in both the
  in-player VS overlay and the vs-mini gallery card).
- Video cards render match titles with flags + corner colours
  ("FINALS – 🇧🇭 Blue vs 🇧🇭 Red (-61 kg)"), matching the video
  page header, and clip long titles to one line.
- Video gallery grid uses repeat(N, minmax(0, 1fr)) + grid-auto-rows:1fr
  so every card thumbnail renders at exactly the same size; hover-preview
  videos use object-fit:cover to stop portrait sources looking smaller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-11 09:14:25 +03:00

548 lines
24 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

@props(['video' => null, 'size' => 'medium'])
@php
use App\Data\Languages;
// When a specific audio track is being surfaced (e.g. via the language
// filter chips), the hover-preview should play that language, not the
// primary. audio-track route serves the track file directly.
$forceTrackIdEarly = $video ? $video->getAttribute('_force_track_id') : null;
$videoUrl = $video
? ($forceTrackIdEarly
? route('videos.audio-track', ['video' => $video, 'track' => $forceTrackIdEarly])
: route('videos.stream', $video))
: null;
$thumbnailUrl = $video && $video->thumbnail
? route('media.thumbnail', $video->thumbnail)
: ($video ? 'https://picsum.photos/seed/' . $video->id . '/640/360' : 'https://picsum.photos/seed/random/640/360');
$typeIcon = $video ? match($video->type) {
'music' => 'bi-music-note',
'match' => 'bi-trophy',
default => 'bi-film',
} : 'bi-film';
// Check if video is shorts
$isShorts = $video && $video->isShorts();
// Check if current user is the owner of the video
$isOwner = $video && auth()->check() && auth()->id() == $video->user_id;
// When surfacing a video via a secondary audio track (e.g. from the
// language filter chips) the controller tags it with these runtime
// attributes so the card links straight to that track and badges the
// thumbnail with its flag.
$forceTrackId = $video ? $video->getAttribute('_force_track_id') : null;
$forceTrackFlag = $video ? $video->getAttribute('_force_track_flag') : null;
// Language flag code (null when no language set). Prefer the forced
// track flag when set — it's the language the card is being surfaced for.
$langFlag = $forceTrackFlag ?: ($video ? Languages::flag($video->language) : null);
$showUrl = $video ? route('videos.show', $video) . ($forceTrackId ? ('?track=' . $forceTrackId) : '') : '#';
// Composed display title for match videos — matches the header on the
// match page: "{match title} {blue flag+name} vs {red flag+name}
// ({weight})", with blue/red corner colours and inline flags. Falls
// back to plain $video->title for non-match cards.
$displayTitle = $video?->title;
$displayTitleHtml = null;
if ($video && $video->type === 'match' && $video->sportsMatch) {
$hdCard = $video->sportsMatch->headerData();
$blueN = $hdCard['blue']['name'] ?? null;
$redN = $hdCard['red']['name'] ?? null;
$blueF = $hdCard['blue']['flag'] ?? null;
$redF = $hdCard['red']['flag'] ?? null;
if ($blueN || $redN) {
$flagStyle = 'width:16px;height:12px;border-radius:2px;display:inline-block;vertical-align:middle;margin-right:4px;';
$mkSide = function ($name, $flag, $color) use ($flagStyle) {
if (!$name) return '';
$flagHtml = $flag ? '<span class="fi fi-'.e($flag).'" style="'.$flagStyle.'"></span>' : '';
return '<span style="color:'.$color.';font-weight:600;">'.$flagHtml.e($name).'</span>';
};
$blueHtml = $mkSide($blueN, $blueF, '#2563eb');
$redHtml = $mkSide($redN, $redF, '#ef4444');
$parts = [];
if (!empty($video->title)) $parts[] = e(strtoupper($video->title)) . ' ';
if ($blueHtml && $redHtml) $parts[] = $blueHtml . ' <span>vs</span> ' . $redHtml;
elseif ($blueHtml) $parts[] = $blueHtml;
elseif ($redHtml) $parts[] = $redHtml;
if (!empty($hdCard['weight_category'])) $parts[] = '(' . e($hdCard['weight_category']) . ')';
$displayTitleHtml = implode(' ', $parts);
}
}
// Size classes
$sizeClasses = match($size) {
'small' => 'yt-video-card-sm',
default => '',
};
@endphp
<div class="yt-video-card {{ $sizeClasses }}" data-video-url="{{ $videoUrl }}">
<a href="{{ $showUrl }}">
<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' }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
@if($video && $video->type === 'match' && $video->sportsMatch)
@include('components.partials.vs-mini', ['video' => $video])
@endif
@if($videoUrl)
{{-- Match cards preload metadata so the first frame is ready the
instant the user hovers (otherwise the browser fetches, buffers
then paints showing a black frame behind the fade). Other
card types keep preload="none" to save bandwidth. --}}
<video preload="{{ $video && $video->type === 'match' ? 'metadata' : 'none' }}" playsinline>
<source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}">
</video>
@endif
@if($video && $video->type === 'match' && $video->sportsMatch)
@include('components.partials.scorebar-mini', ['video' => $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
@if($isShorts)
<span class="yt-shorts-badge">
<i class="bi bi-collection-play-fill"></i> SHORTS
</span>
@endif
@if($forceTrackId && $forceTrackFlag)
<span class="yt-track-lang-badge" title="Plays the {{ strtoupper($video->language ?? '') }} track's language variant">
<span class="fi fi-{{ $forceTrackFlag }}"></span>
<span>Track</span>
</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">
<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 }}" loading="lazy" decoding="async" onload="this.classList.add('loaded')" style="width: 100%; height: 100%; object-fit: cover; border-radius: 50%; opacity: 0; transition: opacity 0.25s ease;">
@endif
</a>
<div class="yt-video-details">
<h3 class="yt-video-title">
<a href="{{ $showUrl }}">
@if($langFlag)
<span class="fi fi-{{ $langFlag }} vc-lang-flag"></span>
@endif
@if($displayTitleHtml)
{!! $displayTitleHtml !!}
@else
{{ $displayTitle ?? 'Untitled Video' }}
@endif
</a>
</h3>
@if($video && $video->user)
<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">
@if($video->type)
<span class="yt-type-label yt-type-{{ $video->type }}">
<i class="bi {{ $typeIcon }}"></i>
{{ ucfirst($video->type === 'match' ? 'Sports' : ($video->type === 'generic' ? 'Video' : $video->type)) }}
</span>
&nbsp;·&nbsp;
@endif
{{ number_format($video->view_count) }} views · {{ $video->created_at->diffForHumans() }}
</div>
@endif
</div>
@if($video)
<div class="position-relative">
<button class="yt-more-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
@if($isOwner)
<li>
<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->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->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->getRouteKey() }}')">
<i class="bi bi-clock"></i> Save to Watch later
</a>
</li>
<li>
<x-save-to-playlist-button :video="$video" tag="a" class="dropdown-item">
<i class="bi bi-bookmark"></i> Save to playlist
</x-save-to-playlist-button>
</li>
@if($video->allow_download)
<li>
<a class="dropdown-item" href="{{ route('videos.download', $video) }}">
<i class="bi bi-download"></i> Download
</a>
</li>
@endif
@if($video->isShareable())
<li>
<x-share-button :video="$video" tag="a" class="dropdown-item">
<i class="bi bi-share"></i> Share
</x-share-button>
</li>
@endif
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="notInterested('{{ $video->getRouteKey() }}')">
<i class="bi bi-dash-circle"></i> Not interested
</a>
</li>
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="dontRecommendChannel('{{ $video->user_id }}')">
<i class="bi bi-x-circle"></i> Don't recommend channel
</a>
</li>
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="reportVideo('{{ $video->getRouteKey() }}')">
<i class="bi bi-flag"></i> Report
</a>
</li>
</ul>
</div>
@endif
</div>
</div>
@php $vk = $video->getRouteKey() ?? ''; @endphp
<!-- Cute Edit Video Modal -->
<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('{{ $vk }}')">×</button>
</div>
<div class="cute-edit-body">
<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-{{ $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-{{ $vk }}" class="cute-textarea" rows="2" placeholder="Tell viewers about your video"></textarea>
</div>
<!-- Video Type -->
<div class="cute-form-group">
<label><i class="bi bi-collection-play"></i> Type</label>
<div class="cute-type-options">
<label class="cute-type-option active" data-type="generic">
<input type="radio" name="type" value="generic" checked>
<span>🎬 Generic</span>
</label>
<label class="cute-type-option" data-type="music">
<input type="radio" name="type" value="music">
<span>🎵 Music</span>
</label>
<label class="cute-type-option" data-type="match">
<input type="radio" name="type" value="match">
<span>🏆 Match</span>
</label>
</div>
</div>
<!-- Shorts Toggle -->
<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-{{ $vk }}" value="1">
<span class="cute-shorts-slider"></span>
<span class="cute-shorts-label">Mark as Short</span>
</label>
</div>
<!-- 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-{{ $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>
</div>
</div>
<!-- Privacy -->
<div class="cute-form-group">
<label><i class="bi bi-shield-lock"></i> Privacy</label>
<div class="cute-privacy-options">
<label class="cute-privacy-option active" data-privacy="public">
<input type="radio" name="visibility" value="public" checked>
<span>🌐 Public</span>
</label>
<label class="cute-privacy-option" data-privacy="unlisted">
<input type="radio" name="visibility" value="unlisted">
<span>🔗 Unlisted</span>
</label>
<label class="cute-privacy-option" data-privacy="private">
<input type="radio" name="visibility" value="private">
<span>🔒 Private</span>
</label>
</div>
</div>
<!-- Status -->
<div class="cute-status" id="edit-status-{{ $vk }}"></div>
<!-- Buttons -->
<div class="cute-edit-actions">
<button type="button" class="cute-btn-cancel" onclick="closeEditVideoModal('{{ $vk }}')">Cancel</button>
<button type="submit" class="cute-btn-save">Save</button>
</div>
</form>
</div>
</div>
</div>
</div>
@include('components.partials.card-styles')
@once
<script>
// Global function to save to Watch Later
function saveToWatchLater(videoId) {
fetch(`/videos/${videoId}/watch-later`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast(data.message || 'Added to Watch Later', 'success');
}
})
.catch(error => console.error('Error:', error));
}
// Global function to add to queue
function addToQueue(videoId) {
showToast('Queue feature coming soon!', 'info');
}
function playVideo(element) {
const video = element.querySelector('video');
if (!video) return;
video.currentTime = 0;
const isAudio = element.dataset.audio === 'true';
video.volume = 0.5;
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) return;
video.pause();
video.currentTime = 0;
video.classList.remove('active');
element.classList.remove('audio-playing');
}
// Edit Modal Functions
let currentEditVideoId = null;
function openEditVideoModal(videoId) {
currentEditVideoId = videoId;
const modalId = 'editVideoModal' + (videoId || '');
const modal = new bootstrap.Modal(document.getElementById(modalId));
modal.show();
// Fetch video data
fetch(`/videos/${videoId}/edit`, {
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
const video = data.video;
document.getElementById('edit-title-' + videoId).value = video.title || '';
document.getElementById('edit-description-' + videoId).value = video.description || '';
// Set type
const typeOptions = document.querySelectorAll('#' + modalId + ' .cute-type-option');
typeOptions.forEach(opt => {
opt.classList.remove('active');
if (opt.dataset.type === (video.type || 'generic')) {
opt.classList.add('active');
opt.querySelector('input').checked = true;
}
});
// Set privacy
const privacyOptions = document.querySelectorAll('#' + modalId + ' .cute-privacy-option');
privacyOptions.forEach(opt => {
opt.classList.remove('active');
if (opt.dataset.privacy === (video.visibility || 'public')) {
opt.classList.add('active');
opt.querySelector('input').checked = true;
}
});
// Set shorts toggle
const shortsCheckbox = document.getElementById('edit-is-shorts-' + videoId);
if (shortsCheckbox) {
shortsCheckbox.checked = video.is_shorts === true || video.is_shorts === 1 || video.is_shorts === '1';
}
// Clear status
const statusEl = document.getElementById('edit-status-' + videoId);
statusEl.className = 'cute-status';
statusEl.textContent = '';
}
})
.catch(error => {
console.error('Error:', error);
});
}
function closeEditVideoModal(videoId) {
const modalId = 'editVideoModal' + (videoId || '');
const modalEl = document.getElementById(modalId);
const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) {
modal.hide();
}
}
// SPA navigation re-executes this once-protected script on every page swap.
// Guard the document-level listeners so they don't stack up across navs.
if (!window._videoCardListenersBound) {
window._videoCardListenersBound = true;
// Type option click handlers
document.addEventListener('click', function(e) {
if (e.target.closest('.cute-type-option')) {
const option = e.target.closest('.cute-type-option');
const parent = option.parentElement;
parent.querySelectorAll('.cute-type-option').forEach(opt => opt.classList.remove('active'));
option.classList.add('active');
option.querySelector('input').checked = true;
}
if (e.target.closest('.cute-privacy-option')) {
const option = e.target.closest('.cute-privacy-option');
const parent = option.parentElement;
parent.querySelectorAll('.cute-privacy-option').forEach(opt => opt.classList.remove('active'));
option.classList.add('active');
option.querySelector('input').checked = true;
}
});
// Thumbnail preview
document.addEventListener('change', function(e) {
if (e.target.id && e.target.id.startsWith('edit-thumbnail-')) {
const file = e.target.files[0];
if (file) {
const videoId = e.target.id.replace('edit-thumbnail-', '');
const preview = document.getElementById('thumbnail-preview-' + videoId);
preview.innerHTML = `<span>${file.name}</span>`;
}
}
});
// Form submission
document.addEventListener('submit', function(e) {
const form = e.target;
if (form.id && form.id.startsWith('edit-video-form-')) {
e.preventDefault();
const videoId = form.id.replace('edit-video-form-', '');
const formData = new FormData(form);
const statusEl = document.getElementById('edit-status-' + videoId);
const submitBtn = form.querySelector('.cute-btn-save');
submitBtn.disabled = true;
submitBtn.textContent = 'Saving...';
fetch(`/videos/${videoId}`, {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
statusEl.className = 'cute-status success';
statusEl.textContent = '✓ Saved successfully!';
setTimeout(() => {
closeEditVideoModal(videoId);
window.location.reload();
}, 1000);
} else {
throw new Error(data.message || 'Update failed');
}
})
.catch(error => {
statusEl.className = 'cute-status error';
statusEl.textContent = '✗ ' + error.message;
submitBtn.disabled = false;
submitBtn.textContent = 'Save';
});
}
});
} // end _videoCardListenersBound guard
</script>
@endonce