From da714958c6a492b0798f97bf9dd3acb7ffaa76c1 Mon Sep 17 00:00:00 2001 From: ghassan Date: Fri, 31 Jul 2026 03:51:45 +0300 Subject: [PATCH] Scrollable, SPA-navigated filter bar with all languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/Http/Controllers/VideoController.php | 8 +- resources/views/layouts/app.blade.php | 40 ++++++- resources/views/videos/index.blade.php | 142 +++++++++++++++++++++++ 3 files changed, 181 insertions(+), 9 deletions(-) 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); + }); +})(); </script> @endsection