Add VideoController@feedMore -> JSON { items, next_cursor, has_more }
that pages the current filter (all / shorts / match / music / lang:xx)
with Laravel's cursorPaginate(24). Cursor is stable across inserts, so
scrolling never duplicates or skips a video when something is uploaded
mid-session.
Client side: a #feed-sentinel + IntersectionObserver (rootMargin
400px) appends the next page of cards to the existing grid without a
page reload. A Load More button is always wired as the fallback when
IO is unavailable or a fetch fails. Both reuse the existing
<x-video-card/> component, so the newly fetched cards get the same
singleton edit modal + width/height thumbs as the server-rendered ones.
The initial 60-card server render stays, so the first viewport is
unchanged. The limit(60) and limit(50) caps in index() can now be
treated as "first page size" rather than a hard ceiling.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
434 lines
19 KiB
PHP
434 lines
19 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;
|
|
/* minmax(0, 1fr) keeps every column exactly equal in width.
|
|
Plain 1fr lets a track grow if a child's min-content is wider
|
|
than the share, which was making the 3rd column wider (and
|
|
therefore taller, since the thumb is 16/9) than the first two. */
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
grid-auto-rows: 1fr;
|
|
align-items: stretch;
|
|
gap: 20px;
|
|
}
|
|
.yt-video-grid > .yt-video-card { height: 100%; min-width: 0; }
|
|
@media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: minmax(0, 1fr); gap: 14px; } }
|
|
|
|
|
|
/* ── Thumbnail orientation fix ── */
|
|
|
|
|
|
/* ── Thumbnail orientation fix ──
|
|
Always cover so every thumbnail visually fills its 16/9 box the
|
|
same way. `!important` beats any leftover inline style from the
|
|
old adjust-fit script (cached page in the wild). */
|
|
.yt-video-card .yt-video-thumb img { object-fit: cover !important; }
|
|
|
|
/* ── 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()) · {{ $searchShorts->count() }} short{{ $searchShorts->count() !== 1 ? 's' : '' }} @endif
|
|
@if($searchPlaylists->count()) · {{ $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
|
|
|
|
{{-- Infinite-scroll sentinel. The IntersectionObserver down in @section('scripts')
|
|
fetches the next cursor page when this comes into view and appends the
|
|
rendered cards to the grid above. A visible "Load more" button is the
|
|
always-available fallback. --}}
|
|
<div id="feed-sentinel"
|
|
data-feed-filter="{{ $filter ?? 'all' }}"
|
|
style="height:1px;"></div>
|
|
<div id="feed-loadmore-wrap" style="display:none; text-align:center; padding:16px 0;">
|
|
<button type="button" id="feed-loadmore" class="action-btn">Load more</button>
|
|
</div>
|
|
</div>{{-- /filter-content --}}
|
|
|
|
@endsection
|
|
|
|
@section('scripts')
|
|
<script>
|
|
/* Thumbnails render at a uniform size: the .yt-video-thumb box is a
|
|
strict 16/9 container and every image inside it uses `object-fit:
|
|
cover` (set in the shared card CSS) so portrait/square art fills the
|
|
frame instead of shrinking with letterboxing. No per-image adjustment
|
|
needed. */
|
|
|
|
/* ─── 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);
|
|
});
|
|
}
|
|
|
|
// No per-image sizing needed — CSS `object-fit: cover` on
|
|
// `.yt-video-thumb img` gives every card the same visible image size.
|
|
function reinitGrid(_root) {}
|
|
|
|
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);
|
|
});
|
|
})();
|
|
|
|
/* ─── Cursor-paginated infinite scroll ───────────────────────────────────
|
|
The server renders the first page of 60 cards. Everything after that is
|
|
fetched here, one 24-card page at a time, when the #feed-sentinel scrolls
|
|
into view. The sentinel sits right after the grid, so we never fetch
|
|
until the user has actually scrolled past the first page.
|
|
|
|
State lives on #feed-sentinel.dataset so a SPA filter-swap can reset it
|
|
without having to rebind listeners. Fallback: an always-visible "Load
|
|
more" button that users without IntersectionObserver can click. */
|
|
(function () {
|
|
const sentinel = document.getElementById('feed-sentinel');
|
|
const grid = document.querySelector('.yt-video-grid');
|
|
const btnWrap = document.getElementById('feed-loadmore-wrap');
|
|
const btn = document.getElementById('feed-loadmore');
|
|
if (!sentinel || !grid) return;
|
|
|
|
let cursor = ''; // null token = next request is the first continuation page
|
|
let busy = false;
|
|
let done = false;
|
|
const filter = sentinel.dataset.feedFilter || 'all';
|
|
|
|
async function loadMore() {
|
|
if (busy || done) return;
|
|
busy = true;
|
|
try {
|
|
const url = new URL("{{ route('videos.feed.more') }}", window.location.origin);
|
|
url.searchParams.set('filter', filter);
|
|
if (cursor) url.searchParams.set('cursor', cursor);
|
|
const resp = await fetch(url.toString(), {
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
|
|
credentials: 'same-origin',
|
|
});
|
|
if (!resp.ok) throw new Error('bad status: ' + resp.status);
|
|
const data = await resp.json();
|
|
if (data.items) {
|
|
grid.insertAdjacentHTML('beforeend', data.items);
|
|
}
|
|
cursor = data.next_cursor || '';
|
|
done = !data.has_more;
|
|
} catch (e) {
|
|
// Reveal the manual Load More so a flaky network doesn't dead-end the user.
|
|
btnWrap.style.display = 'block';
|
|
} finally {
|
|
busy = false;
|
|
if (done) {
|
|
btnWrap.style.display = 'none';
|
|
sentinel.style.display = 'none';
|
|
}
|
|
}
|
|
}
|
|
|
|
btn?.addEventListener('click', loadMore);
|
|
|
|
if ('IntersectionObserver' in window) {
|
|
const io = new IntersectionObserver((entries) => {
|
|
if (entries.some(e => e.isIntersecting)) loadMore();
|
|
}, { rootMargin: '400px 0px' });
|
|
io.observe(sentinel);
|
|
} else {
|
|
// No IntersectionObserver → surface the Load More fallback immediately.
|
|
btnWrap.style.display = 'block';
|
|
}
|
|
})();
|
|
</script>
|
|
@endsection
|