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:
ghassanandClaude Opus 4.7 committed 2026-10-21 10:09:40 +03:00
1 parent 6b2a67c4cd
commit 45b5f586ba
5 files changed
+164 -9

No files matched your search

+96
View File
@@ -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' => [],
]);
}
}
+5 -4
View File
@@ -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>
+52 -1
View File
@@ -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 */
@@ -114,7 +142,8 @@
@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">
@@ -122,11 +151,33 @@
<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()) &nbsp;·&nbsp; {{ $searchShorts->count() }} short{{ $searchShorts->count() !== 1 ? 's' : '' }} @endif @if($searchShorts->count()) &nbsp;·&nbsp; {{ $searchShorts->count() }} short{{ $searchShorts->count() !== 1 ? 's' : '' }} @endif
@if($searchChannels->count()) &nbsp;·&nbsp; {{ $searchChannels->count() }} channel{{ $searchChannels->count() !== 1 ? 's' : '' }} @endif
@if($searchPlaylists->count()) &nbsp;·&nbsp; {{ $searchPlaylists->count() }} playlist{{ $searchPlaylists->count() !== 1 ? 's' : '' }} @endif @if($searchPlaylists->count()) &nbsp;·&nbsp; {{ $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
View File
@@ -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');