diff --git a/app/Http/Controllers/VideoController.php b/app/Http/Controllers/VideoController.php
index c9e6c11..3abdb5c 100644
--- a/app/Http/Controllers/VideoController.php
+++ b/app/Http/Controllers/VideoController.php
@@ -139,9 +139,11 @@ class VideoController extends Controller
/**
* Distinct languages currently present in public content, sorted by how
* 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');
if ($publicIds->isEmpty()) return [];
@@ -178,7 +180,7 @@ class VideoController extends Controller
if (! $flag) continue; // skip unknown codes
$name = \App\Data\Languages::all()[$code]['name'] ?? strtoupper($code);
$chips[] = ['code' => $code, 'flag' => $flag, 'name' => $name, 'count' => $count];
- if (count($chips) >= $limit) break;
+ if ($limit !== null && count($chips) >= $limit) break;
}
return $chips;
}
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php
index 43b54d8..bb2242a 100644
--- a/resources/views/layouts/app.blade.php
+++ b/resources/views/layouts/app.blade.php
@@ -435,6 +435,14 @@
.action-btn.icon-only { padding: 8px; }
/* 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 {
display: flex;
gap: 12px;
@@ -442,15 +450,35 @@
scrollbar-width: none;
-ms-overflow-style: none;
padding: 12px 24px;
- margin: -24px -24px 20px;
- background: var(--bg-dark);
- position: sticky;
- top: 56px;
- z-index: 90;
- border-bottom: 1px solid var(--border-color);
+ scroll-behavior: smooth;
}
.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 {
flex-shrink: 0;
background: #3f3f3f;
diff --git a/resources/views/videos/index.blade.php b/resources/views/videos/index.blade.php
index c434c6f..84e0124 100644
--- a/resources/views/videos/index.blade.php
+++ b/resources/views/videos/index.blade.php
@@ -46,6 +46,9 @@
{{-- ── Filter chip bar ── --}}
@unless(isset($query))
@php $activeFilter = request('filter', 'all'); @endphp
+
+
+
All
@@ -75,8 +78,10 @@
@endforeach
@endif
+
{{-- /yt-filter-bar-wrap --}}
@endunless
+
{{-- ══════════════════════════════════════════════
SEARCH RESULTS
══════════════════════════════════════════════ --}}
@@ -196,6 +201,7 @@
@endif
@endif
+
{{-- /filter-content --}}
@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 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);
+ });
+})();
@endsection