From a25af30700695c542cbde7be77c3ccd59cb5c30b Mon Sep 17 00:00:00 2001 From: ghassan Date: Wed, 21 Oct 2026 10:03:39 +0300 Subject: [PATCH] fix(channel): grid, scoreboard thumb, sort hash, visitor privacy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit fixes against /channel/{slug}: 1. Fluid video grid — repeat(auto-fill, minmax(260px, 1fr)) replaces the three fixed breakpoints (3/2/4 cols at 1101/1500/… px) that produced different card widths and could overflow 390 / 1280 / 1920. min-width:0 on every grid child so a long unbroken title (CJK, "SPARTAWORRIOR") can't widen its track past 1fr. 2. Scoreboard thumbnail — stop including vs-mini + scorebar-mini on match cards. Those partials leaked fighter names, "AKA/AO", "ROUND 1", "0:00" into the card DOM, showing stale 0-0 for finished matches and breaking title layout at narrow widths. The regular above is the thumbnail now; VS intro + live scorebar still render on the full match player page where they belong. 3. Sort pills — Latest/Popular/Oldest now append #videos so the page stays on the Videos tab after reload instead of jumping to Wall. 4. Shorts tab — always render for the owner (so the "My Shorts" sidebar link always lands somewhere), with an empty-state card when no shorts exist. Visitors still see it only when shorts > 0. 5. Subscriptions sidebar link — href="#" replaced by a real /feed/subscriptions route backed by UserController@subscriptionsFeed that reuses the videos/index view with the subscription feed. 6. Header view count — totalViews now sums video_views over public videos for visitors (owner still sees the full total). Previously leaked unlisted and private video view counts. 7. Card redundancy — new hideChannelInfo prop on suppresses the per-card avatar + channel-name row. The channel page passes it; everywhere else keeps the row. 9. Decorative logo — the second Play in the responsive header pair now has alt="" so a screen reader doesn't announce "Play Play". ⋮ menu button gets aria-label="More actions". 10. Privacy — age, gender, location and horoscope strip now only render to the owner on the channel header (horoscope strip is also allowed when the compatibility match-up is being shown to a signed-in visitor). Joined-month stays public. 11. Notifications dropdown — initial state is "No notifications" not an indefinite "Loading…". Opening the panel now shows a spinner while fetching, surfaces a Retry button on error, and bails out of the "Loading…" state on non-ok HTTP responses. Title clamp — .yt-video-title is now a 2-line box with overflow-wrap:anywhere so CJK titles and long words wrap mid-word and can't widen the card past its track. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Http/Controllers/UserController.php | 38 ++++++++++++- .../components/partials/card-styles.blade.php | 33 ++++++----- .../views/components/video-card.blade.php | 25 ++++---- resources/views/layouts/app.blade.php | 17 +++++- .../views/layouts/partials/header.blade.php | 9 ++- .../views/layouts/partials/sidebar.blade.php | 3 +- resources/views/user/channel.blade.php | 57 ++++++++++++++----- .../partials/channel/styles/desktop.blade.php | 15 +++-- routes/web.php | 1 + 9 files changed, 148 insertions(+), 50 deletions(-) diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 756fe70..4755cb2 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -262,8 +262,16 @@ class UserController extends Controller ? $user->playlists()->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get() : $user->playlists()->public()->where('is_default', false)->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get(); + // Header stats must not leak unlisted/private video view counts to + // visitors. Owners (and ?preview=1 is handled above by setting + // $isOwner=false) see totals across every video they uploaded; + // visitors see totals across public videos only — matching what + // the videos count row already shows. + $viewVideoIds = $isOwner + ? $user->videos()->pluck('id') + : Video::public()->where('user_id', $user->id)->pluck('id'); $totalViews = \DB::table('video_views') - ->whereIn('video_id', $user->videos()->pluck('id')) + ->whereIn('video_id', $viewVideoIds) ->count(); // Filter social links by visibility for the current viewer @@ -345,6 +353,34 @@ class UserController extends Controller return redirect()->route('history')->with('toast_success', 'Watch history cleared.'); } + /** + * Subscriptions feed — the public videos uploaded by every channel the + * viewer is subscribed to, newest first. Replaces the dead href="#" that + * the sidebar's "Subscriptions" item used to point at. Thin on purpose: + * reuses the existing video-card component via the videos/index view. + */ + public function subscriptionsFeed() + { + $me = Auth::user(); + $channelIds = $me->subscriptions()->pluck('users.id'); + + $videos = Video::public() + ->whereIn('user_id', $channelIds) + ->notShorts() + ->latest() + ->limit(60) + ->get(); + + return view('videos.index', [ + 'videos' => $videos, + 'filter' => 'subscriptions', + 'langChips' => [], + 'shorts' => collect(), + 'matches' => collect(), + 'playlists' => collect(), + ]); + } + // Liked videos public function liked() { diff --git a/resources/views/components/partials/card-styles.blade.php b/resources/views/components/partials/card-styles.blade.php index b7143f1..79df8d2 100644 --- a/resources/views/components/partials/card-styles.blade.php +++ b/resources/views/components/partials/card-styles.blade.php @@ -245,12 +245,15 @@ display: flex; margin-top: 12px; gap: 12px; - /* Hard-cap the info block to guarantee every card has the same total - height (thumb + info). Overflow hidden so any accidental extra - row can't push the card taller than its neighbours. */ - height: 76px; - overflow: hidden; + /* Reserve a floor for the info block so every card lands at the same + height when titles are short. Two-line titles are allowed to grow + the block vertically — overflow:visible so no row is hidden. */ + min-height: 76px; } +/* On channel pages the avatar + channel-name row is suppressed by the + component's hideChannelInfo prop; drop the floor so the card doesn't + leave empty space where the row used to be. */ +.yt-video-card .yt-video-info-no-channel { min-height: 60px; } .yt-video-card .yt-video-details { min-width: 0; flex: 1; @@ -284,22 +287,26 @@ color: #fff; margin: 0 0 4px; line-height: 1.3; - /* Single-line, no wrapping — overflow ellipses. */ - white-space: nowrap; + /* Two-line clamp. overflow-wrap:anywhere lets CJK titles and long + words (e.g. "SPARTAWORRIOR" or "EVIS ― 帰還を待つ覇者の鼓動") + break mid-word so they can't widen the card beyond its grid track. + Line-clamp replaces the single-line ellipsis the old rule used. */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; overflow: hidden; - text-overflow: ellipsis; + overflow-wrap: anywhere; + word-break: break-word; + max-height: calc(1.3em * 2); } .yt-video-card .yt-video-title a { color: inherit; text-decoration: none; - /* Block-level with nowrap so ellipsis actually clips the excess. - Inline-flex would let the children overflow past the parent. */ display: block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; } /* Inline children (flags, corner-colored fighter spans) must not break the single-line layout. */ diff --git a/resources/views/components/video-card.blade.php b/resources/views/components/video-card.blade.php index cab3c38..a269539 100644 --- a/resources/views/components/video-card.blade.php +++ b/resources/views/components/video-card.blade.php @@ -1,4 +1,4 @@ -@props(['video' => null, 'size' => 'medium']) +@props(['video' => null, 'size' => 'medium', 'hideChannelInfo' => false]) @php use App\Data\Languages; @@ -93,9 +93,6 @@ $sizeClasses = match($size) { width="320" height="180" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')"> - @if($video && $video->type === 'match' && $video->sportsMatch) - @include('components.partials.vs-mini', ['video' => $video]) - @endif @if($videoUrl) {{-- Match cards preload metadata so the first frame is ready the instant the user hovers (otherwise the browser fetches, buffers @@ -105,9 +102,13 @@ $sizeClasses = match($size) { @endif - @if($video && $video->type === 'match' && $video->sportsMatch) - @include('components.partials.scorebar-mini', ['video' => $video]) - @endif + {{-- Match cards used to inline the full VS portrait + the live-scoring + scorebar as thumbnail overlays. Those partials leaked fighter + names, "AKA / AO", "ROUND 1", "0:00" and club text into the + card DOM — showing stale 0-0 for finished matches, breaking + the title layout at narrow widths, and inflating card HTML. + The regular above is the thumbnail now; the VS intro + and live scorebar live on the full match player page only. --}} {{-- Equalizer overlay shown when audio-only track is previewing --}}
@@ -139,13 +140,15 @@ $sizeClasses = match($size) { @endif
-
+
+ @unless($hideChannelInfo) @if($video && $video->user && $video->user->avatar_url) {{ $video->user->name }} @endif + @endunless

@@ -159,7 +162,7 @@ $sizeClasses = match($size) { @endif

- @if($video && $video->user) + @if($video && $video->user && ! $hideChannelInfo) {{ $video->user->name }} @endif @@ -178,8 +181,8 @@ $sizeClasses = match($size) {
@if($video)
-
@@ -48,7 +51,7 @@ onclick="markAllRead()" style="display:none;">Mark all read
-
Loading…
+
No notifications
diff --git a/resources/views/layouts/partials/sidebar.blade.php b/resources/views/layouts/partials/sidebar.blade.php index fd645cd..872bdf4 100644 --- a/resources/views/layouts/partials/sidebar.blade.php +++ b/resources/views/layouts/partials/sidebar.blade.php @@ -16,7 +16,8 @@ Trending @auth - + Subscriptions diff --git a/resources/views/user/channel.blade.php b/resources/views/user/channel.blade.php index 41b76f1..57d503b 100644 --- a/resources/views/user/channel.blade.php +++ b/resources/views/user/channel.blade.php @@ -136,7 +136,14 @@ $headerSocialMap = [ {{ number_format($user->subscriber_count) }} {{ Str::plural('subscriber', $user->subscriber_count) }} - @if($user->location) + {{-- Location, age and gender used to render in the public + header meta row — visible to every visitor. That's a + privacy leak the user flagged; those three now show + only to the owner here, and only in the About tab for + everyone (via the existing ch-about-card below). Join + month stays public because it's channel metadata, not + personal data. --}} + @if($isOwner && $user->location) · @@ -148,7 +155,7 @@ $headerSocialMap = [ Joined {{ $user->created_at->format('M Y') }} - @if($user->birthday) + @if($isOwner && $user->birthday) @php $age = \Carbon\Carbon::parse($user->birthday)->age; @endphp · @@ -156,13 +163,13 @@ $headerSocialMap = [ {{ $age }} yrs @endif - @if($user->gender === 'male') + @if($isOwner && $user->gender === 'male') · Male - @elseif($user->gender === 'female') + @elseif($isOwner && $user->gender === 'female') · @@ -181,18 +188,25 @@ $headerSocialMap = [ @endif - {{-- Horoscope + Compatibility strip & Social buttons side by side --}} - @if($horoscope || $socialLinks->isNotEmpty()) + {{-- Horoscope + Compatibility strip & Social buttons side by side. + The horoscope portion is sign-derived from the birthday the + owner entered; it only renders to the owner (or to a visitor + who's seeing the compatibility match-up). Pure social buttons + still show to visitors. --}} + @php + $hColor = $horoscope ? \App\Helpers\Horoscope::elementColor($horoscope['element']) : null; + $showCompat = $horoscope && ($compatibility !== null && $viewerSign && !$isOwner); + $showHoroStrip = $horoscope && ($isOwner || $showCompat); + @endphp + @if($showHoroStrip || $socialLinks->isNotEmpty()) @php - $hColor = $horoscope ? \App\Helpers\Horoscope::elementColor($horoscope['element']) : null; - $showCompat = $horoscope && ($compatibility !== null && $viewerSign && !$isOwner); if ($showCompat) { $compatColor = $compatibility >= 75 ? '#22c55e' : ($compatibility >= 55 ? '#eab308' : '#ef4444'); $compatLabel = $compatibility >= 85 ? 'Soulmates' : ($compatibility >= 70 ? 'Great match' : ($compatibility >= 55 ? 'Good vibes' : ($compatibility >= 40 ? 'Some friction' : 'Tough combo'))); } @endphp
- @if($horoscope) + @if($showHoroStrip)
@@ -361,10 +375,15 @@ $headerSocialMap = [ {{ $videos->count() }} @endif - @if($shorts->count() > 0) + {{-- Always show the Shorts tab for the owner so the "My Shorts" + sidebar link always lands somewhere; for visitors only when + there's content to show. --}} + @if($isOwner || $shorts->count() > 0) @endif @if(($playlists && $playlists->count() > 0) || $isOwner) @@ -663,18 +682,18 @@ $headerSocialMap = [
Sort
@foreach($videos as $video) - @include('components.video-card', ['video' => $video]) + @include('components.video-card', ['video' => $video, 'hideChannelInfo' => true]) @endforeach
@@ -699,8 +718,15 @@ $headerSocialMap = [
{{-- ─────────────────────── SHORTS TAB ──────────────── --}} -@if($shorts->count() > 0) +@if($isOwner || $shorts->count() > 0)
+ @if($shorts->isEmpty()) +
+
+

No shorts yet

+

Vertical videos under 60 seconds appear here.

+
+ @else
@foreach($shorts as $short) @@ -722,6 +748,7 @@ $headerSocialMap = [ @endforeach
+ @endif
@endif diff --git a/resources/views/user/partials/channel/styles/desktop.blade.php b/resources/views/user/partials/channel/styles/desktop.blade.php index e79a7fb..99d4f2b 100644 --- a/resources/views/user/partials/channel/styles/desktop.blade.php +++ b/resources/views/user/partials/channel/styles/desktop.blade.php @@ -342,10 +342,17 @@ /* ══════════════════════════════════════════════════ VIDEO GRID ══════════════════════════════════════════════════ */ -.yt-video-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; } -@media (max-width: 1100px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } } -@media (max-width: 600px) { .yt-video-grid { grid-template-columns: 1fr; } } -@media (min-width: 1500px) { .yt-video-grid { grid-template-columns: repeat(4, 1fr); } } +/* Fluid grid: as many 260-px columns as fit, each one stretching to fill + the leftover space. Removes the three fixed breakpoints that produced + different card widths at 1101px vs 1500px, and `min-width:0` on every + grid child so a long unbroken title (CJK, "SPARTAWORRIOR") can't widen + its track past the configured 1fr. */ +.yt-video-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 20px; +} +.yt-video-grid > * { min-width: 0; } /* ══════════════════════════════════════════════════ SHORTS GRID diff --git a/routes/web.php b/routes/web.php index 31d2d89..f2d3039 100644 --- a/routes/web.php +++ b/routes/web.php @@ -162,6 +162,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/history', [UserController::class, 'history'])->name('history'); Route::delete('/history', [UserController::class, 'clearHistory'])->name('history.clear'); Route::get('/liked', [UserController::class, 'liked'])->name('liked'); + Route::get('/feed/subscriptions', [UserController::class, 'subscriptionsFeed'])->name('subscriptions.feed'); // Logout all other devices Route::post('/settings/logout-all-devices', [UserController::class, 'logoutAllDevices'])->name('settings.logoutAllDevices');