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>
This commit is contained in:
ghassan 2026-07-31 03:51:45 +03:00
parent 847020fd02
commit da714958c6
3 changed files with 181 additions and 9 deletions

View File

@ -139,9 +139,11 @@ class VideoController extends Controller
/** /**
* Distinct languages currently present in public content, sorted by how * Distinct languages currently present in public content, sorted by how
* many videos each covers (primary + secondary tracks combined). Returned * many videos each covers (primary + secondary tracks combined). Returned
* as [ ['code'=>'ja','flag'=>'jp','name'=>'Japanese','count'=>N], ... ] * as [ ['code'=>'ja','flag'=>'jp','name'=>'Japanese','count'=>N], ... ].
* Pass a $limit only if the caller wants a preview; the bar itself is
* horizontally scrollable so the default is uncapped.
*/ */
protected function buildLanguageChips(int $limit = 12): array protected function buildLanguageChips(?int $limit = null): array
{ {
$publicIds = \App\Models\Video::public()->pluck('id'); $publicIds = \App\Models\Video::public()->pluck('id');
if ($publicIds->isEmpty()) return []; if ($publicIds->isEmpty()) return [];
@ -178,7 +180,7 @@ class VideoController extends Controller
if (! $flag) continue; // skip unknown codes if (! $flag) continue; // skip unknown codes
$name = \App\Data\Languages::all()[$code]['name'] ?? strtoupper($code); $name = \App\Data\Languages::all()[$code]['name'] ?? strtoupper($code);
$chips[] = ['code' => $code, 'flag' => $flag, 'name' => $name, 'count' => $count]; $chips[] = ['code' => $code, 'flag' => $flag, 'name' => $name, 'count' => $count];
if (count($chips) >= $limit) break; if ($limit !== null && count($chips) >= $limit) break;
} }
return $chips; return $chips;
} }

View File

@ -435,6 +435,14 @@
.action-btn.icon-only { padding: 8px; } .action-btn.icon-only { padding: 8px; }
/* Filter chip bar */ /* Filter chip bar */
.yt-filter-bar-wrap {
position: sticky;
top: 56px;
z-index: 90;
margin: -24px -24px 20px;
background: var(--bg-dark);
border-bottom: 1px solid var(--border-color);
}
.yt-filter-bar { .yt-filter-bar {
display: flex; display: flex;
gap: 12px; gap: 12px;
@ -442,15 +450,35 @@
scrollbar-width: none; scrollbar-width: none;
-ms-overflow-style: none; -ms-overflow-style: none;
padding: 12px 24px; padding: 12px 24px;
margin: -24px -24px 20px; scroll-behavior: smooth;
background: var(--bg-dark);
position: sticky;
top: 56px;
z-index: 90;
border-bottom: 1px solid var(--border-color);
} }
.yt-filter-bar::-webkit-scrollbar { display: none; } .yt-filter-bar::-webkit-scrollbar { display: none; }
/* Left / right scroll buttons — visible only when the bar overflows */
.yt-filter-nav {
position: absolute; top: 0; bottom: 0;
display: none; align-items: center;
width: 48px;
background: transparent;
border: none;
color: var(--text-primary);
cursor: pointer;
z-index: 2;
padding: 0;
font-size: 20px;
justify-content: center;
}
.yt-filter-nav.visible { display: flex; }
.yt-filter-nav.left { left: 0; background: linear-gradient(to right, var(--bg-dark) 40%, transparent); justify-content: flex-start; padding-left: 6px; }
.yt-filter-nav.right { right: 0; background: linear-gradient(to left, var(--bg-dark) 40%, transparent); justify-content: flex-end; padding-right: 6px; }
.yt-filter-nav i {
width: 32px; height: 32px; border-radius: 50%;
background: var(--bg-secondary);
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 6px rgba(0,0,0,0.35);
}
.yt-filter-nav:hover i { background: #555; }
.yt-chip { .yt-chip {
flex-shrink: 0; flex-shrink: 0;
background: #3f3f3f; background: #3f3f3f;

View File

@ -46,6 +46,9 @@
{{-- ── Filter chip bar ── --}} {{-- ── Filter chip bar ── --}}
@unless(isset($query)) @unless(isset($query))
@php $activeFilter = request('filter', 'all'); @endphp @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"> <div class="yt-filter-bar">
<a href="{{ route('videos.index') }}" <a href="{{ route('videos.index') }}"
class="yt-chip {{ $activeFilter === 'all' ? 'active' : '' }}">All</a> class="yt-chip {{ $activeFilter === 'all' ? 'active' : '' }}">All</a>
@ -75,8 +78,10 @@
@endforeach @endforeach
@endif @endif
</div> </div>
</div>{{-- /yt-filter-bar-wrap --}}
@endunless @endunless
<div id="filter-content">
{{-- ══════════════════════════════════════════════ {{-- ══════════════════════════════════════════════
SEARCH RESULTS SEARCH RESULTS
══════════════════════════════════════════════ --}} ══════════════════════════════════════════════ --}}
@ -196,6 +201,7 @@
@endif @endif
@endif @endif
</div>{{-- /filter-content --}}
@endsection @endsection
@ -213,5 +219,141 @@
} }
}); });
})(); })();
/* ─── 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> </script>
@endsection @endsection