- 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>
359 lines
15 KiB
PHP
359 lines
15 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', isset($query) ? 'Search: ' . $query . ' | ' . config('app.name') : config('app.name'))
|
|
|
|
@section('extra_styles')
|
|
<style>
|
|
/* ── Regular video grid ── */
|
|
.yt-video-grid {
|
|
display: grid;
|
|
/* minmax(0, 1fr) keeps every column exactly equal in width.
|
|
Plain 1fr lets a track grow if a child's min-content is wider
|
|
than the share, which was making the 3rd column wider (and
|
|
therefore taller, since the thumb is 16/9) than the first two. */
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
grid-auto-rows: 1fr;
|
|
align-items: stretch;
|
|
gap: 20px;
|
|
}
|
|
.yt-video-grid > .yt-video-card { height: 100%; min-width: 0; }
|
|
@media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: minmax(0, 1fr); gap: 14px; } }
|
|
|
|
|
|
/* ── Thumbnail orientation fix ── */
|
|
|
|
|
|
/* ── Thumbnail orientation fix ──
|
|
Always cover so every thumbnail visually fills its 16/9 box the
|
|
same way. `!important` beats any leftover inline style from the
|
|
old adjust-fit script (cached page in the wild). */
|
|
.yt-video-card .yt-video-thumb img { object-fit: cover !important; }
|
|
|
|
/* ── 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; }
|
|
|
|
/* Language chips */
|
|
.yt-chip-sep { display:inline-block; width:1px; align-self:stretch; margin:6px 4px; background:var(--border-color); }
|
|
.yt-chip-lang { display:inline-flex; align-items:center; gap:6px; }
|
|
.yt-chip-lang .fi { width:18px; height:13px; border-radius:2px; display:inline-block; flex-shrink:0; }
|
|
</style>
|
|
@endsection
|
|
|
|
@section('content')
|
|
|
|
{{-- ── Filter chip bar ── --}}
|
|
@unless(isset($query))
|
|
@php $activeFilter = request('filter', 'all'); @endphp
|
|
<div class="yt-filter-bar-wrap">
|
|
<button type="button" class="yt-filter-nav left" aria-label="Scroll left" onclick="scrollFilterBar(-1)"><i class="bi bi-chevron-left"></i></button>
|
|
<button type="button" class="yt-filter-nav right" aria-label="Scroll right" onclick="scrollFilterBar(1)"><i class="bi bi-chevron-right"></i></button>
|
|
<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>
|
|
|
|
@if(!empty($langChips ?? []))
|
|
@php $activeLang = str_starts_with($activeFilter, 'lang:') ? substr($activeFilter, 5) : null; @endphp
|
|
<span class="yt-chip-sep" aria-hidden="true"></span>
|
|
@foreach($langChips as $chip)
|
|
<a href="{{ route('videos.index', ['filter' => 'lang:' . $chip['code']]) }}"
|
|
class="yt-chip yt-chip-lang {{ $activeLang === $chip['code'] ? 'active' : '' }}"
|
|
title="{{ $chip['name'] }} ({{ $chip['count'] }})">
|
|
<span class="fi fi-{{ $chip['flag'] }}"></span>
|
|
<span>{{ $chip['name'] }}</span>
|
|
</a>
|
|
@endforeach
|
|
@endif
|
|
</div>
|
|
</div>{{-- /yt-filter-bar-wrap --}}
|
|
@endunless
|
|
|
|
<div id="filter-content">
|
|
{{-- ══════════════════════════════════════════════
|
|
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">
|
|
<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])
|
|
@endif
|
|
@endforeach
|
|
</div>
|
|
@else
|
|
<div class="yt-empty">
|
|
<h2>No content yet</h2>
|
|
@auth
|
|
<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)
|
|
@include('components.video-card', ['video' => $video])
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
|
|
@endif
|
|
</div>{{-- /filter-content --}}
|
|
|
|
@endsection
|
|
|
|
@section('scripts')
|
|
<script>
|
|
/* Thumbnails render at a uniform size: the .yt-video-thumb box is a
|
|
strict 16/9 container and every image inside it uses `object-fit:
|
|
cover` (set in the shared card CSS) so portrait/square art fills the
|
|
frame instead of shrinking with letterboxing. No per-image adjustment
|
|
needed. */
|
|
|
|
/* ─── Filter-bar horizontal scroll ─── */
|
|
(function () {
|
|
var bar = document.querySelector('.yt-filter-bar');
|
|
var wrap = document.querySelector('.yt-filter-bar-wrap');
|
|
if (!bar || !wrap) return;
|
|
|
|
var leftBtn = wrap.querySelector('.yt-filter-nav.left');
|
|
var rightBtn = wrap.querySelector('.yt-filter-nav.right');
|
|
|
|
function updateNav() {
|
|
var canLeft = bar.scrollLeft > 4;
|
|
var canRight = (bar.scrollLeft + bar.clientWidth) < (bar.scrollWidth - 4);
|
|
if (leftBtn) leftBtn.classList.toggle('visible', canLeft);
|
|
if (rightBtn) rightBtn.classList.toggle('visible', canRight);
|
|
}
|
|
|
|
// Expose for the inline onclick handlers.
|
|
window.scrollFilterBar = function (dir) {
|
|
bar.scrollBy({ left: dir * Math.max(240, bar.clientWidth * 0.8), behavior: 'smooth' });
|
|
};
|
|
|
|
// Convert vertical wheel to horizontal — but only when there's no
|
|
// shift key (shift+wheel already scrolls horizontally in browsers).
|
|
bar.addEventListener('wheel', function (e) {
|
|
if (e.deltaY === 0 || e.shiftKey) return;
|
|
// Only intercept when the bar can actually scroll in that direction,
|
|
// otherwise let the page scroll normally.
|
|
var going = e.deltaY > 0 ? 1 : -1;
|
|
var atEdge = (going > 0 && bar.scrollLeft + bar.clientWidth >= bar.scrollWidth - 1)
|
|
|| (going < 0 && bar.scrollLeft <= 0);
|
|
if (atEdge) return;
|
|
e.preventDefault();
|
|
bar.scrollLeft += e.deltaY;
|
|
}, { passive: false });
|
|
|
|
bar.addEventListener('scroll', updateNav, { passive: true });
|
|
window.addEventListener('resize', updateNav);
|
|
|
|
// Persist scroll position across navigations so clicking a chip
|
|
// doesn't snap the bar back to the start.
|
|
var STORAGE_KEY = 'ytFilterBarScroll';
|
|
try {
|
|
var saved = parseInt(sessionStorage.getItem(STORAGE_KEY) || '0', 10);
|
|
if (saved > 0) {
|
|
// Wait a tick so layout is settled and scrollWidth is accurate.
|
|
requestAnimationFrame(function () { bar.scrollLeft = saved; updateNav(); });
|
|
}
|
|
} catch (e) {}
|
|
|
|
bar.addEventListener('scroll', function () {
|
|
try { sessionStorage.setItem(STORAGE_KEY, String(bar.scrollLeft)); } catch (e) {}
|
|
}, { passive: true });
|
|
|
|
updateNav();
|
|
})();
|
|
|
|
/* ─── SPA chip navigation — no full page reload ─── */
|
|
(function () {
|
|
var bar = document.querySelector('.yt-filter-bar');
|
|
if (!bar) return;
|
|
|
|
// Only /videos* URLs are handled in-place — trending/shorts render
|
|
// different templates and are cheaper to just navigate to.
|
|
function isSpaUrl(href) {
|
|
try {
|
|
var u = new URL(href, window.location.origin);
|
|
if (u.origin !== window.location.origin) return false;
|
|
return u.pathname.replace(/\/+$/, '') === '/videos';
|
|
} catch (e) { return false; }
|
|
}
|
|
|
|
function setActiveChip(url) {
|
|
var target = new URL(url, window.location.origin);
|
|
bar.querySelectorAll('.yt-chip').forEach(function (a) {
|
|
var u;
|
|
try { u = new URL(a.href, window.location.origin); } catch (e) { return; }
|
|
var same = u.pathname === target.pathname && u.search === target.search;
|
|
a.classList.toggle('active', same);
|
|
});
|
|
}
|
|
|
|
// No per-image sizing needed — CSS `object-fit: cover` on
|
|
// `.yt-video-thumb img` gives every card the same visible image size.
|
|
function reinitGrid(_root) {}
|
|
|
|
var currentReq = 0;
|
|
async function swapTo(url, pushHist) {
|
|
var reqId = ++currentReq;
|
|
var container = document.getElementById('filter-content');
|
|
if (!container) { window.location.href = url; return; }
|
|
container.style.opacity = '0.5';
|
|
try {
|
|
var resp = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'text/html' }, credentials: 'same-origin' });
|
|
if (reqId !== currentReq) return; // a newer click superseded us
|
|
if (!resp.ok) { window.location.href = url; return; }
|
|
var html = await resp.text();
|
|
var doc = new DOMParser().parseFromString(html, 'text/html');
|
|
var next = doc.getElementById('filter-content');
|
|
if (!next) { window.location.href = url; return; }
|
|
container.innerHTML = next.innerHTML;
|
|
reinitGrid(container);
|
|
setActiveChip(url);
|
|
if (pushHist !== false) history.pushState({ filterUrl: url }, '', url);
|
|
// Update <title> in case the new page has a different one
|
|
var t = doc.querySelector('title');
|
|
if (t) document.title = t.textContent;
|
|
window.scrollTo({ top: 0, behavior: 'instant' in window ? 'instant' : 'auto' });
|
|
} catch (e) {
|
|
window.location.href = url;
|
|
return;
|
|
} finally {
|
|
container.style.opacity = '';
|
|
}
|
|
}
|
|
|
|
// Delegate: any .yt-chip click within the bar (event delegation survives
|
|
// DOM swaps of the grid, though the bar itself isn't swapped).
|
|
bar.addEventListener('click', function (e) {
|
|
var a = e.target.closest('a.yt-chip');
|
|
if (!a || !bar.contains(a)) return;
|
|
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
var href = a.getAttribute('href');
|
|
if (!href || !isSpaUrl(href)) return;
|
|
e.preventDefault();
|
|
swapTo(href, true);
|
|
});
|
|
|
|
window.addEventListener('popstate', function (e) {
|
|
if (e.state && e.state.filterUrl) swapTo(e.state.filterUrl, false);
|
|
});
|
|
})();
|
|
</script>
|
|
@endsection
|