feat(search): /search?q= across videos, channels and playlists
Add a dedicated SearchController that matches the query against video title / description / tags / primary language AND any secondary audio track language (so "arabic" surfaces videos that carry an Arabic track even if the primary language is English), plus channel name / username / bio, plus playlist name / description. Public visibility only. Results reuse the videos/index view so every card gets the same component the browse feeds use. A new Channels section renders above the Videos grid when channel hits exist. /videos/search is kept as a 301 redirect to /search so bookmarks and external links stay valid; both the desktop header and mobile overlay search forms now point at the canonical route, both carry a visually hidden <label>, both get role="search" and both preserve the current query in their input. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
6b2a67c4cd
commit
45b5f586ba
5 files changed
+166
-11
No files matched your search
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Playlist;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Video;
|
||||||
|
use App\Models\VideoAudioTrack;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site-wide search across videos, channels and playlists.
|
||||||
|
*
|
||||||
|
* Lives on /search?q=… so the URL reads like what you'd expect from a modern
|
||||||
|
* site. The old /videos/search endpoint still works but redirects here, so
|
||||||
|
* nothing bookmarked earlier breaks.
|
||||||
|
*
|
||||||
|
* Match surface:
|
||||||
|
* - Video title, description, tag list, primary language and the language
|
||||||
|
* of any secondary audio track attached to it.
|
||||||
|
* - User name, username (handle), and short bio.
|
||||||
|
* - Playlist name and description.
|
||||||
|
*
|
||||||
|
* We render the existing `videos.index` view so every search result lands in
|
||||||
|
* the same card component the browse feeds use — one place to style, one place
|
||||||
|
* to fix bugs.
|
||||||
|
*/
|
||||||
|
class SearchController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$query = trim((string) $request->get('q', ''));
|
||||||
|
|
||||||
|
if ($query === '') {
|
||||||
|
// No query → send the user back to the browse feed instead of an
|
||||||
|
// empty "no results" page.
|
||||||
|
return redirect()->route('videos.index');
|
||||||
|
}
|
||||||
|
|
||||||
|
$like = '%' . $query . '%';
|
||||||
|
$qLower = mb_strtolower($query);
|
||||||
|
|
||||||
|
// ── Videos ────────────────────────────────────────────────
|
||||||
|
// Match on text columns AND on any audio-track language that aligns
|
||||||
|
// with the query (e.g. searching "arabic" surfaces videos that carry
|
||||||
|
// an Arabic track even if the primary language is English).
|
||||||
|
$trackVideoIds = VideoAudioTrack::whereRaw('LOWER(language) = ?', [$qLower])
|
||||||
|
->pluck('video_id');
|
||||||
|
|
||||||
|
$videoHits = Video::public()
|
||||||
|
->where(function ($q) use ($like, $qLower, $trackVideoIds) {
|
||||||
|
$q->where('title', 'like', $like)
|
||||||
|
->orWhere('description','like', $like)
|
||||||
|
->orWhere('tags', 'like', $like)
|
||||||
|
->orWhereRaw('LOWER(language) = ?', [$qLower])
|
||||||
|
->orWhereIn('id', $trackVideoIds);
|
||||||
|
})
|
||||||
|
->latest()
|
||||||
|
->limit(60)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$videos = $videoHits->where('is_shorts', false)->values();
|
||||||
|
$shorts = $videoHits->where('is_shorts', true)->values();
|
||||||
|
|
||||||
|
// ── Channels ──────────────────────────────────────────────
|
||||||
|
$channels = User::where(function ($q) use ($like) {
|
||||||
|
$q->where('name', 'like', $like)
|
||||||
|
->orWhere('username','like', $like)
|
||||||
|
->orWhere('bio', 'like', $like);
|
||||||
|
})
|
||||||
|
->orderByDesc('id')
|
||||||
|
->limit(12)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// ── Playlists ─────────────────────────────────────────────
|
||||||
|
$playlists = Playlist::where('visibility', 'public')
|
||||||
|
->where(function ($q) use ($like) {
|
||||||
|
$q->where('name', 'like', $like)
|
||||||
|
->orWhere('description','like', $like);
|
||||||
|
})
|
||||||
|
->withCount('videos')
|
||||||
|
->with(['user', 'videos' => fn ($q) => $q->orderBy('playlist_videos.position')->limit(1)])
|
||||||
|
->latest()
|
||||||
|
->limit(12)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('videos.index', [
|
||||||
|
'videos' => $videos,
|
||||||
|
'shorts' => $shorts,
|
||||||
|
'playlists' => $playlists,
|
||||||
|
'channels' => $channels,
|
||||||
|
'query' => $query,
|
||||||
|
'langChips' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1237,10 +1237,11 @@
|
|||||||
|
|
||||||
<!-- Mobile Search Overlay -->
|
<!-- Mobile Search Overlay -->
|
||||||
<div class="mobile-search-overlay" id="mobileSearchOverlay">
|
<div class="mobile-search-overlay" id="mobileSearchOverlay">
|
||||||
<form action="{{ route('videos.search') }}" method="GET" class="mobile-search-form">
|
<form action="{{ route('search') }}" method="GET" class="mobile-search-form" role="search">
|
||||||
<input type="text" name="q" class="mobile-search-input" placeholder="Search" value="{{ request('q') }}">
|
<label for="mobile-search-input" class="visually-hidden">Search</label>
|
||||||
<button type="submit" class="mobile-search-submit">
|
<input type="search" id="mobile-search-input" name="q" class="mobile-search-input" placeholder="Search" value="{{ request('q') }}" aria-label="Search videos, channels and playlists">
|
||||||
<i class="bi bi-search"></i>
|
<button type="submit" class="mobile-search-submit" aria-label="Submit search">
|
||||||
|
<i class="bi bi-search" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,9 +15,10 @@
|
|||||||
|
|
||||||
<div class="yt-header-center d-none d-md-flex">
|
<div class="yt-header-center d-none d-md-flex">
|
||||||
<div class="yt-search">
|
<div class="yt-search">
|
||||||
<form action="{{ route('videos.search') }}" method="GET" class="yt-search-form">
|
<form action="{{ route('search') }}" method="GET" class="yt-search-form" role="search">
|
||||||
<input type="text" name="q" class="yt-search-input" placeholder="Search" value="{{ request('q') }}">
|
<label for="header-search-input" class="visually-hidden">Search</label>
|
||||||
<button type="submit" class="yt-search-btn"><i class="bi bi-search"></i></button>
|
<input type="search" id="header-search-input" name="q" class="yt-search-input" placeholder="Search" value="{{ request('q') }}" aria-label="Search videos, channels and playlists">
|
||||||
|
<button type="submit" class="yt-search-btn" aria-label="Submit search"><i class="bi bi-search" aria-hidden="true"></i></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,6 +42,34 @@
|
|||||||
}
|
}
|
||||||
.results-section-title::after { content: ''; flex: 1; height: 1px; background: var(--border-color); }
|
.results-section-title::after { content: ''; flex: 1; height: 1px; background: var(--border-color); }
|
||||||
|
|
||||||
|
/* Channel results — card list sized to sit comfortably above the video
|
||||||
|
grid. Avatar is a fixed 56px circle; name + handle sit to the right. */
|
||||||
|
.search-channels-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.search-channel-card {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: background .15s ease;
|
||||||
|
}
|
||||||
|
.search-channel-card:hover { background: rgba(255,255,255,.08); }
|
||||||
|
.search-channel-card img,
|
||||||
|
.search-channel-ph {
|
||||||
|
width: 56px; height: 56px; border-radius: 50%; object-fit: cover; flex: 0 0 auto;
|
||||||
|
background: #333; display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 22px; color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.search-channel-text { min-width: 0; }
|
||||||
|
.search-channel-name { font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.search-channel-handle { font-size: 13px; color: var(--text-secondary); }
|
||||||
|
|
||||||
.yt-empty { text-align: center; padding: 80px 20px; }
|
.yt-empty { text-align: center; padding: 80px 20px; }
|
||||||
|
|
||||||
/* Language chips */
|
/* Language chips */
|
||||||
@@ -112,21 +140,44 @@
|
|||||||
@isset($query)
|
@isset($query)
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$searchShorts = $shorts ?? collect();
|
$searchShorts = $shorts ?? collect();
|
||||||
$searchPlaylists = $playlists ?? collect();
|
$searchPlaylists = $playlists ?? collect();
|
||||||
$totalResults = $videos->count() + $searchShorts->count() + $searchPlaylists->count();
|
$searchChannels = $channels ?? collect();
|
||||||
|
$totalResults = $videos->count() + $searchShorts->count() + $searchPlaylists->count() + $searchChannels->count();
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="search-info">
|
<div class="search-info">
|
||||||
<h2>Search results for "{{ $query }}"</h2>
|
<h2>Search results for "{{ $query }}"</h2>
|
||||||
<p>
|
<p>
|
||||||
@if($videos->count()) {{ $videos->count() }} video{{ $videos->count() !== 1 ? 's' : '' }} @endif
|
@if($videos->count()) {{ $videos->count() }} video{{ $videos->count() !== 1 ? 's' : '' }} @endif
|
||||||
@if($searchShorts->count()) · {{ $searchShorts->count() }} short{{ $searchShorts->count() !== 1 ? 's' : '' }} @endif
|
@if($searchShorts->count()) · {{ $searchShorts->count() }} short{{ $searchShorts->count() !== 1 ? 's' : '' }} @endif
|
||||||
|
@if($searchChannels->count()) · {{ $searchChannels->count() }} channel{{ $searchChannels->count() !== 1 ? 's' : '' }} @endif
|
||||||
@if($searchPlaylists->count()) · {{ $searchPlaylists->count() }} playlist{{ $searchPlaylists->count() !== 1 ? 's' : '' }} @endif
|
@if($searchPlaylists->count()) · {{ $searchPlaylists->count() }} playlist{{ $searchPlaylists->count() !== 1 ? 's' : '' }} @endif
|
||||||
@if($totalResults === 0) No results found @endif
|
@if($totalResults === 0) No results found @endif
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if($searchChannels->count())
|
||||||
|
<div class="results-section-title"><i class="bi bi-person-circle"></i> Channels</div>
|
||||||
|
<div class="search-channels-grid">
|
||||||
|
@foreach($searchChannels as $channel)
|
||||||
|
<a href="{{ route('channel', $channel->channel) }}" class="search-channel-card">
|
||||||
|
@if($channel->avatar_url)
|
||||||
|
<img src="{{ $channel->avatar_url }}" alt="{{ $channel->name }}" width="56" height="56" loading="lazy">
|
||||||
|
@else
|
||||||
|
<div class="search-channel-ph"><i class="bi bi-person"></i></div>
|
||||||
|
@endif
|
||||||
|
<div class="search-channel-text">
|
||||||
|
<div class="search-channel-name">{{ $channel->name }}</div>
|
||||||
|
@if($channel->username)
|
||||||
|
<div class="search-channel-handle">@{{ $channel->username }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if($videos->count())
|
@if($videos->count())
|
||||||
<div class="results-section-title"><i class="bi bi-play-circle"></i> Videos</div>
|
<div class="results-section-title"><i class="bi bi-play-circle"></i> Videos</div>
|
||||||
<div class="yt-video-grid">
|
<div class="yt-video-grid">
|
||||||
|
|||||||
+7
-1
@@ -25,7 +25,13 @@ Route::get('/', [VideoController::class, 'index'])->name('home');
|
|||||||
// Video routes - public
|
// Video routes - public
|
||||||
Route::get('/videos', [VideoController::class, 'index'])->name('videos.index');
|
Route::get('/videos', [VideoController::class, 'index'])->name('videos.index');
|
||||||
Route::get('/videos/feed/more', [VideoController::class, 'feedMore'])->name('videos.feed.more');
|
Route::get('/videos/feed/more', [VideoController::class, 'feedMore'])->name('videos.feed.more');
|
||||||
Route::get('/videos/search', [VideoController::class, 'search'])->name('videos.search');
|
|
||||||
|
// Site-wide search. /videos/search is the historical URL; keep it as a
|
||||||
|
// signed-forward redirect so bookmarks and external links don't break.
|
||||||
|
Route::get('/search', [\App\Http\Controllers\SearchController::class, 'index'])->name('search');
|
||||||
|
Route::get('/videos/search', function (\Illuminate\Http\Request $request) {
|
||||||
|
return redirect()->route('search', $request->only('q'), 301);
|
||||||
|
})->name('videos.search');
|
||||||
Route::get('/trending', [VideoController::class, 'trending'])->name('videos.trending');
|
Route::get('/trending', [VideoController::class, 'trending'])->name('videos.trending');
|
||||||
Route::get('/shorts', [VideoController::class, 'shorts'])->name('videos.shorts');
|
Route::get('/shorts', [VideoController::class, 'shorts'])->name('videos.shorts');
|
||||||
Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create');
|
Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create');
|
||||||
|
|||||||
Reference in new issue
Block a user