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

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()) &nbsp;·&nbsp; {{ $searchShorts->count() }} short{{ $searchShorts->count() !== 1 ? 's' : '' }} @endif
@if($searchPlaylists->count()) &nbsp;·&nbsp; {{ $searchPlaylists->count() }} playlist{{ $searchPlaylists->count() !== 1 ? 's' : '' }} @endif
@if($totalResults === 0) No results found @endif
</p>
</div>
@if($videos->count())
<div class="results-section-title"><i class="bi bi-play-circle"></i> Videos</div>
<div class="yt-video-grid">
@foreach($videos as $video)
@include('components.video-card', ['video' => $video])
@endforeach
</div>
@endif
@if($searchShorts->count())
<div class="results-section-title"><i class="bi bi-lightning-charge-fill"></i> Shorts</div>
<div class="yt-video-grid">
@foreach($searchShorts as $video)
@include('components.video-card', ['video' => $video])
@endforeach
</div>
@endif
@if($searchPlaylists->count())
<div class="results-section-title"><i class="bi bi-collection-play"></i> Playlists</div>
<div class="yt-video-grid">
@foreach($searchPlaylists as $pl)
@include('components.playlist-card', ['playlist' => $pl])
@endforeach
</div>
@endif
@if($totalResults === 0)
<div class="yt-empty">
<i class="bi bi-search" style="font-size:56px;color:var(--text-secondary);display:block;margin-bottom:12px;"></i>
<h2>No results for "{{ $query }}"</h2>
<p style="color:var(--text-secondary);">Try different keywords or browse the gallery.</p>
</div>
@endif
@endisset
{{-- ══════════════════════════════════════════════
PLAYLISTS-ONLY FILTER
══════════════════════════════════════════════ --}}
@if(!isset($query) && isset($filter) && $filter === 'playlists')
@if(isset($playlists) && $playlists->count())
<div class="yt-video-grid">
@foreach($playlists as $pl)
@include('components.playlist-card', ['playlist' => $pl])
@endforeach
</div>
@else
<div class="yt-empty">
<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