fix(channel): grid, scoreboard thumb, sort hash, visitor privacy

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 <img> 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 <x-video-card/>
   suppresses the per-card avatar + channel-name row. The channel
   page passes it; everywhere else keeps the row.
9. Decorative logo — the second <img alt="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) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-10-21 10:03:39 +03:00
1 parent d9f97a672e
commit a25af30700
9 files changed
+145 -47

No files matched your search

+37 -1
View File
@@ -262,8 +262,16 @@ class UserController extends Controller
? $user->playlists()->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get() ? $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(); : $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') $totalViews = \DB::table('video_views')
->whereIn('video_id', $user->videos()->pluck('id')) ->whereIn('video_id', $viewVideoIds)
->count(); ->count();
// Filter social links by visibility for the current viewer // 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.'); 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 // Liked videos
public function liked() public function liked()
{ {
@@ -245,12 +245,15 @@
display: flex; display: flex;
margin-top: 12px; margin-top: 12px;
gap: 12px; gap: 12px;
/* Hard-cap the info block to guarantee every card has the same total /* Reserve a floor for the info block so every card lands at the same
height (thumb + info). Overflow hidden so any accidental extra height when titles are short. Two-line titles are allowed to grow
row can't push the card taller than its neighbours. */ the block vertically — overflow:visible so no row is hidden. */
height: 76px; min-height: 76px;
overflow: hidden;
} }
/* 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 { .yt-video-card .yt-video-details {
min-width: 0; min-width: 0;
flex: 1; flex: 1;
@@ -284,22 +287,26 @@
color: #fff; color: #fff;
margin: 0 0 4px; margin: 0 0 4px;
line-height: 1.3; line-height: 1.3;
/* Single-line, no wrapping — overflow ellipses. */ /* Two-line clamp. overflow-wrap:anywhere lets CJK titles and long
white-space: nowrap; 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; 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 { .yt-video-card .yt-video-title a {
color: inherit; color: inherit;
text-decoration: none; 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; display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%; max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
} }
/* Inline children (flags, corner-colored fighter spans) must not break /* Inline children (flags, corner-colored fighter spans) must not break
the single-line layout. */ the single-line layout. */
+14 -11
View File
@@ -1,4 +1,4 @@
@props(['video' => null, 'size' => 'medium']) @props(['video' => null, 'size' => 'medium', 'hideChannelInfo' => false])
@php @php
use App\Data\Languages; use App\Data\Languages;
@@ -93,9 +93,6 @@ $sizeClasses = match($size) {
width="320" height="180" width="320" height="180"
loading="lazy" decoding="async" loading="lazy" decoding="async"
onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')"> 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) @if($videoUrl)
{{-- Match cards preload metadata so the first frame is ready the {{-- Match cards preload metadata so the first frame is ready the
instant the user hovers (otherwise the browser fetches, buffers instant the user hovers (otherwise the browser fetches, buffers
@@ -105,9 +102,13 @@ $sizeClasses = match($size) {
<source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}"> <source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}">
</video> </video>
@endif @endif
@if($video && $video->type === 'match' && $video->sportsMatch) {{-- Match cards used to inline the full VS portrait + the live-scoring
@include('components.partials.scorebar-mini', ['video' => $video]) scorebar as thumbnail overlays. Those partials leaked fighter
@endif 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 <img> 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 --}} {{-- Equalizer overlay shown when audio-only track is previewing --}}
<div class="audio-preview-overlay"> <div class="audio-preview-overlay">
<div class="audio-eq"> <div class="audio-eq">
@@ -139,13 +140,15 @@ $sizeClasses = match($size) {
@endif @endif
</div> </div>
</a> </a>
<div class="yt-video-info"> <div class="yt-video-info {{ $hideChannelInfo ? 'yt-video-info-no-channel' : '' }}">
@unless($hideChannelInfo)
<a href="{{ $video && $video->user ? route('channel', $video->user->channel) : '#' }}" <a href="{{ $video && $video->user ? route('channel', $video->user->channel) : '#' }}"
class="yt-channel-icon" onclick="event.stopPropagation();"> class="yt-channel-icon" onclick="event.stopPropagation();">
@if($video && $video->user && $video->user->avatar_url) @if($video && $video->user && $video->user->avatar_url)
<img src="{{ $video->user->avatar_url }}" alt="{{ $video->user->name }}" loading="lazy" decoding="async" onload="this.classList.add('loaded')" style="width: 100%; height: 100%; object-fit: cover; border-radius: 50%; opacity: 0; transition: opacity 0.25s ease;"> <img src="{{ $video->user->avatar_url }}" alt="{{ $video->user->name }}" loading="lazy" decoding="async" onload="this.classList.add('loaded')" style="width: 100%; height: 100%; object-fit: cover; border-radius: 50%; opacity: 0; transition: opacity 0.25s ease;">
@endif @endif
</a> </a>
@endunless
<div class="yt-video-details"> <div class="yt-video-details">
<h3 class="yt-video-title"> <h3 class="yt-video-title">
<a href="{{ $showUrl }}"> <a href="{{ $showUrl }}">
@@ -159,7 +162,7 @@ $sizeClasses = match($size) {
@endif @endif
</a> </a>
</h3> </h3>
@if($video && $video->user) @if($video && $video->user && ! $hideChannelInfo)
<a href="{{ route('channel', $video->user->channel) }}" <a href="{{ route('channel', $video->user->channel) }}"
class="yt-channel-name" onclick="event.stopPropagation();">{{ $video->user->name }}</a> class="yt-channel-name" onclick="event.stopPropagation();">{{ $video->user->name }}</a>
@endif @endif
@@ -178,8 +181,8 @@ $sizeClasses = match($size) {
</div> </div>
@if($video) @if($video)
<div class="position-relative"> <div class="position-relative">
<button class="yt-more-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false"> <button class="yt-more-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false" aria-label="More actions">
<i class="bi bi-three-dots-vertical"></i> <i class="bi bi-three-dots-vertical" aria-hidden="true"></i>
</button> </button>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark"> <ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
@if($isOwner) @if($isOwner)
+15 -2
View File
@@ -1697,11 +1697,24 @@
}; };
function loadNotifications() { function loadNotifications() {
// Switch the pre-rendered "No notifications" placeholder to a
// spinner while the fetch is in flight, then let the response
// handler replace it with the real list, an empty state, or
// an error message. Previously the panel just said "Loading…"
// forever if the fetch threw synchronously or returned non-JSON.
list.innerHTML = '<div class="yt-notif-empty"><i class="bi bi-arrow-repeat spin"></i> Loading…</div>';
fetch(fetchUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } }) fetch(fetchUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); }) .then(function (r) {
if (!r.ok) throw new Error('http ' + r.status);
return r.json();
})
.then(function (data) { loaded = true; renderNotifications(data); }) .then(function (data) { loaded = true; renderNotifications(data); })
.catch(function () { list.innerHTML = '<div class="yt-notif-empty">Could not load notifications.</div>'; }); .catch(function () {
list.innerHTML = '<div class="yt-notif-empty">Could not load notifications. <button type="button" class="yt-notif-retry" onclick="loadNotifications()">Retry</button></div>';
});
} }
// Expose for the inline Retry button above.
window.loadNotifications = loadNotifications;
window.toggleNotifPanel = function () { window.toggleNotifPanel = function () {
var btn = document.getElementById('notifBtn'); var btn = document.getElementById('notifBtn');
@@ -5,8 +5,11 @@
<i class="bi bi-list fs-5"></i> <i class="bi bi-list fs-5"></i>
</button> </button>
<a href="{{ route('home') }}" class="yt-logo"> <a href="{{ route('home') }}" class="yt-logo">
{{-- Two responsive logos. The visible one at a given viewport
carries the app name; its sibling is display:none and gets
alt="" so a screen reader doesn't announce "Play Play". --}}
<img src="{{ asset('storage/images/logo.png') }}" alt="{{ config('app.name') }}" class="d-md-none" style="height:30px;"> <img src="{{ asset('storage/images/logo.png') }}" alt="{{ config('app.name') }}" class="d-md-none" style="height:30px;">
<img src="{{ asset('storage/images/fullLogo.png') }}" alt="{{ config('app.name') }}" class="d-none d-md-block" style="height:30px;"> <img src="{{ asset('storage/images/fullLogo.png') }}" alt="" class="d-none d-md-block" style="height:30px;">
</a> </a>
</div> </div>
@@ -48,7 +51,7 @@
onclick="markAllRead()" style="display:none;">Mark all read</button> onclick="markAllRead()" style="display:none;">Mark all read</button>
</div> </div>
<div class="yt-notif-list" id="notifList"> <div class="yt-notif-list" id="notifList">
<div class="yt-notif-empty">Loading…</div> <div class="yt-notif-empty" id="notifInitialState">No notifications</div>
</div> </div>
</div> </div>
</div> </div>
@@ -16,7 +16,8 @@
<span>Trending</span> <span>Trending</span>
</a> </a>
@auth @auth
<a href="#" class="yt-sidebar-link"> <a href="{{ route('subscriptions.feed') }}"
class="yt-sidebar-link {{ request()->routeIs('subscriptions.feed') ? 'active' : '' }}">
<i class="bi bi-collection-play-fill"></i> <i class="bi bi-collection-play-fill"></i>
<span>Subscriptions</span> <span>Subscriptions</span>
</a> </a>
+40 -13
View File
@@ -136,7 +136,14 @@ $headerSocialMap = [
<i class="bi bi-people-fill"></i> <i class="bi bi-people-fill"></i>
{{ number_format($user->subscriber_count) }} {{ Str::plural('subscriber', $user->subscriber_count) }} {{ number_format($user->subscriber_count) }} {{ Str::plural('subscriber', $user->subscriber_count) }}
</span> </span>
@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)
<span class="ch-meta-sep">·</span> <span class="ch-meta-sep">·</span>
<span class="ch-meta-item"> <span class="ch-meta-item">
<i class="bi bi-geo-alt-fill"></i> <i class="bi bi-geo-alt-fill"></i>
@@ -148,7 +155,7 @@ $headerSocialMap = [
<i class="bi bi-calendar3"></i> <i class="bi bi-calendar3"></i>
Joined {{ $user->created_at->format('M Y') }} Joined {{ $user->created_at->format('M Y') }}
</span> </span>
@if($user->birthday) @if($isOwner && $user->birthday)
@php $age = \Carbon\Carbon::parse($user->birthday)->age; @endphp @php $age = \Carbon\Carbon::parse($user->birthday)->age; @endphp
<span class="ch-meta-sep">·</span> <span class="ch-meta-sep">·</span>
<span class="ch-meta-item"> <span class="ch-meta-item">
@@ -156,13 +163,13 @@ $headerSocialMap = [
{{ $age }} yrs {{ $age }} yrs
</span> </span>
@endif @endif
@if($user->gender === 'male') @if($isOwner && $user->gender === 'male')
<span class="ch-meta-sep">·</span> <span class="ch-meta-sep">·</span>
<span class="ch-meta-item" style="color:#3b82f6;font-weight:700;"> <span class="ch-meta-item" style="color:#3b82f6;font-weight:700;">
<i class="bi bi-gender-male" style="font-size:14px;-webkit-text-stroke:.4px #3b82f6;filter:drop-shadow(0 0 6px #3b82f640);"></i> <i class="bi bi-gender-male" style="font-size:14px;-webkit-text-stroke:.4px #3b82f6;filter:drop-shadow(0 0 6px #3b82f640);"></i>
Male Male
</span> </span>
@elseif($user->gender === 'female') @elseif($isOwner && $user->gender === 'female')
<span class="ch-meta-sep">·</span> <span class="ch-meta-sep">·</span>
<span class="ch-meta-item" style="color:#ec4899;font-weight:700;"> <span class="ch-meta-item" style="color:#ec4899;font-weight:700;">
<i class="bi bi-gender-female" style="font-size:14px;-webkit-text-stroke:.4px #ec4899;filter:drop-shadow(0 0 6px #ec489940);"></i> <i class="bi bi-gender-female" style="font-size:14px;-webkit-text-stroke:.4px #ec4899;filter:drop-shadow(0 0 6px #ec489940);"></i>
@@ -181,18 +188,25 @@ $headerSocialMap = [
</div> </div>
@endif @endif
{{-- Horoscope + Compatibility strip & Social buttons side by side --}} {{-- Horoscope + Compatibility strip & Social buttons side by side.
@if($horoscope || $socialLinks->isNotEmpty()) 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 @php
$hColor = $horoscope ? \App\Helpers\Horoscope::elementColor($horoscope['element']) : null; $hColor = $horoscope ? \App\Helpers\Horoscope::elementColor($horoscope['element']) : null;
$showCompat = $horoscope && ($compatibility !== null && $viewerSign && !$isOwner); $showCompat = $horoscope && ($compatibility !== null && $viewerSign && !$isOwner);
$showHoroStrip = $horoscope && ($isOwner || $showCompat);
@endphp
@if($showHoroStrip || $socialLinks->isNotEmpty())
@php
if ($showCompat) { if ($showCompat) {
$compatColor = $compatibility >= 75 ? '#22c55e' : ($compatibility >= 55 ? '#eab308' : '#ef4444'); $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'))); $compatLabel = $compatibility >= 85 ? 'Soulmates' : ($compatibility >= 70 ? 'Great match' : ($compatibility >= 55 ? 'Good vibes' : ($compatibility >= 40 ? 'Some friction' : 'Tough combo')));
} }
@endphp @endphp
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:14px;"> <div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:14px;">
@if($horoscope) @if($showHoroStrip)
<div class="ch-horo-strip" style="margin-bottom:0; cursor:pointer;" <div class="ch-horo-strip" style="margin-bottom:0; cursor:pointer;"
onclick="switchTab('about',document.querySelector('[data-tab=about]'))" title="Click to view horoscope"> onclick="switchTab('about',document.querySelector('[data-tab=about]'))" title="Click to view horoscope">
<div class="ch-horo-sign-part"> <div class="ch-horo-sign-part">
@@ -361,10 +375,15 @@ $headerSocialMap = [
<span class="ch-tab-badge">{{ $videos->count() }}</span> <span class="ch-tab-badge">{{ $videos->count() }}</span>
@endif @endif
</button> </button>
@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)
<button class="ch-tab" data-tab="shorts" onclick="switchTab('shorts', this)"> <button class="ch-tab" data-tab="shorts" onclick="switchTab('shorts', this)">
Shorts Shorts
@if($shorts->count() > 0)
<span class="ch-tab-badge">{{ $shorts->count() }}</span> <span class="ch-tab-badge">{{ $shorts->count() }}</span>
@endif
</button> </button>
@endif @endif
@if(($playlists && $playlists->count() > 0) || $isOwner) @if(($playlists && $playlists->count() > 0) || $isOwner)
@@ -663,18 +682,18 @@ $headerSocialMap = [
<div class="ch-sort-bar" style="margin-bottom:0; flex-shrink:0;"> <div class="ch-sort-bar" style="margin-bottom:0; flex-shrink:0;">
<span class="ch-sort-label">Sort</span> <span class="ch-sort-label">Sort</span>
<div class="ch-sort-pills"> <div class="ch-sort-pills">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'latest']) }}" <a href="{{ request()->fullUrlWithQuery(['sort' => 'latest']) . '#videos' }}"
class="ch-sort-pill {{ $sort === 'latest' ? 'active' : '' }}">Latest</a> class="ch-sort-pill {{ $sort === 'latest' ? 'active' : '' }}">Latest</a>
<a href="{{ request()->fullUrlWithQuery(['sort' => 'popular']) }}" <a href="{{ request()->fullUrlWithQuery(['sort' => 'popular']) . '#videos' }}"
class="ch-sort-pill {{ $sort === 'popular' ? 'active' : '' }}">Popular</a> class="ch-sort-pill {{ $sort === 'popular' ? 'active' : '' }}">Popular</a>
<a href="{{ request()->fullUrlWithQuery(['sort' => 'oldest']) }}" <a href="{{ request()->fullUrlWithQuery(['sort' => 'oldest']) . '#videos' }}"
class="ch-sort-pill {{ $sort === 'oldest' ? 'active' : '' }}">Oldest</a> class="ch-sort-pill {{ $sort === 'oldest' ? 'active' : '' }}">Oldest</a>
</div> </div>
</div> </div>
</div> </div>
<div class="yt-video-grid" id="chVidGrid"> <div class="yt-video-grid" id="chVidGrid">
@foreach($videos as $video) @foreach($videos as $video)
@include('components.video-card', ['video' => $video]) @include('components.video-card', ['video' => $video, 'hideChannelInfo' => true])
@endforeach @endforeach
<div class="ch-vid-no-results" id="chVidNoResults"> <div class="ch-vid-no-results" id="chVidNoResults">
<i class="bi bi-search"></i> <i class="bi bi-search"></i>
@@ -699,8 +718,15 @@ $headerSocialMap = [
</div> </div>
{{-- ─────────────────────── SHORTS TAB ──────────────── --}} {{-- ─────────────────────── SHORTS TAB ──────────────── --}}
@if($shorts->count() > 0) @if($isOwner || $shorts->count() > 0)
<div class="ch-tab-content" id="tab-shorts"> <div class="ch-tab-content" id="tab-shorts">
@if($shorts->isEmpty())
<div class="ch-empty">
<div class="ch-empty-icon"><i class="bi bi-phone"></i></div>
<h3>No shorts yet</h3>
<p>Vertical videos under 60 seconds appear here.</p>
</div>
@else
<div class="ch-shorts-grid"> <div class="ch-shorts-grid">
@foreach($shorts as $short) @foreach($shorts as $short)
<a href="{{ route('videos.show', $short) }}" class="ch-short-card"> <a href="{{ route('videos.show', $short) }}" class="ch-short-card">
@@ -722,6 +748,7 @@ $headerSocialMap = [
</a> </a>
@endforeach @endforeach
</div> </div>
@endif
</div> </div>
@endif @endif
@@ -342,10 +342,17 @@
/* ══════════════════════════════════════════════════ /* ══════════════════════════════════════════════════
VIDEO GRID VIDEO GRID
══════════════════════════════════════════════════ */ ══════════════════════════════════════════════════ */
.yt-video-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; } /* Fluid grid: as many 260-px columns as fit, each one stretching to fill
@media (max-width: 1100px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } } the leftover space. Removes the three fixed breakpoints that produced
@media (max-width: 600px) { .yt-video-grid { grid-template-columns: 1fr; } } different card widths at 1101px vs 1500px, and `min-width:0` on every
@media (min-width: 1500px) { .yt-video-grid { grid-template-columns: repeat(4, 1fr); } } 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 SHORTS GRID
+1
View File
@@ -162,6 +162,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/history', [UserController::class, 'history'])->name('history'); Route::get('/history', [UserController::class, 'history'])->name('history');
Route::delete('/history', [UserController::class, 'clearHistory'])->name('history.clear'); Route::delete('/history', [UserController::class, 'clearHistory'])->name('history.clear');
Route::get('/liked', [UserController::class, 'liked'])->name('liked'); Route::get('/liked', [UserController::class, 'liked'])->name('liked');
Route::get('/feed/subscriptions', [UserController::class, 'subscriptionsFeed'])->name('subscriptions.feed');
// Logout all other devices // Logout all other devices
Route::post('/settings/logout-all-devices', [UserController::class, 'logoutAllDevices'])->name('settings.logoutAllDevices'); Route::post('/settings/logout-all-devices', [UserController::class, 'logoutAllDevices'])->name('settings.logoutAllDevices');