ghassan da714958c6 Scrollable, SPA-navigated filter bar with all languages
Removes the 12-chip cap on the language filter so every language that
appears in public content is listed (video 175 alone contributes 12).

The bar wrapper is now sticky and horizontally scrollable with chevron
buttons that appear only when the bar overflows in that direction, a
gradient fade behind each button, vertical-wheel → horizontal scroll
that hands off to the page at the edges, and sessionStorage-persisted
scroll position so navigation doesn't snap it back to the start.

Chip clicks are intercepted (only for /videos URLs; Trending/Shorts
still render different templates via a normal navigation) and swap
only the #filter-content region via fetch + DOMParser, updating
history.pushState, document.title, and active-chip state without a
full page reload. Modifier-clicks and middle-clicks fall through
to native "open in new tab" behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-31 03:51:45 +03:00

360 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;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
@media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: 1fr; gap: 14px; } }
/* ── Thumbnail orientation fix ── */
/* ── Thumbnail orientation fix ── */
.yt-video-card .yt-video-thumb img { object-fit: cover; }
/* ── 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>
(function () {
function adjustPlThumb(img) {
img.style.objectFit = img.naturalWidth < img.naturalHeight ? 'contain' : 'cover';
}
document.querySelectorAll('.yt-video-thumb img').forEach(function (img) {
if (img.complete && img.naturalWidth) {
adjustPlThumb(img);
} else {
img.addEventListener('load', function () { adjustPlThumb(img); });
}
});
})();
/* ─── 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);
});
}
// Re-run any grid-scoped init that the page's other IIFE did on first load.
function reinitGrid(root) {
root.querySelectorAll('.yt-video-thumb img').forEach(function (img) {
function fit() { img.style.objectFit = img.naturalWidth < img.naturalHeight ? 'contain' : 'cover'; }
if (img.complete && img.naturalWidth) fit(); else img.addEventListener('load', fit);
});
}
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