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