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:
1 parent
d9f97a672e
commit
a25af30700
9 files changed
+145
-47
No files matched your search
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) {
|
||||
<source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}">
|
||||
</video>
|
||||
@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 <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 --}}
|
||||
<div class="audio-preview-overlay">
|
||||
<div class="audio-eq">
|
||||
@@ -139,13 +140,15 @@ $sizeClasses = match($size) {
|
||||
@endif
|
||||
</div>
|
||||
</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) : '#' }}"
|
||||
class="yt-channel-icon" onclick="event.stopPropagation();">
|
||||
@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;">
|
||||
@endif
|
||||
</a>
|
||||
@endunless
|
||||
<div class="yt-video-details">
|
||||
<h3 class="yt-video-title">
|
||||
<a href="{{ $showUrl }}">
|
||||
@@ -159,7 +162,7 @@ $sizeClasses = match($size) {
|
||||
@endif
|
||||
</a>
|
||||
</h3>
|
||||
@if($video && $video->user)
|
||||
@if($video && $video->user && ! $hideChannelInfo)
|
||||
<a href="{{ route('channel', $video->user->channel) }}"
|
||||
class="yt-channel-name" onclick="event.stopPropagation();">{{ $video->user->name }}</a>
|
||||
@endif
|
||||
@@ -178,8 +181,8 @@ $sizeClasses = match($size) {
|
||||
</div>
|
||||
@if($video)
|
||||
<div class="position-relative">
|
||||
<button class="yt-more-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false">
|
||||
<i class="bi bi-three-dots-vertical"></i>
|
||||
<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" aria-hidden="true"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
|
||||
@if($isOwner)
|
||||
|
||||
@@ -1697,11 +1697,24 @@
|
||||
};
|
||||
|
||||
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' } })
|
||||
.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); })
|
||||
.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 () {
|
||||
var btn = document.getElementById('notifBtn');
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
<i class="bi bi-list fs-5"></i>
|
||||
</button>
|
||||
<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/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>
|
||||
</div>
|
||||
|
||||
@@ -48,7 +51,7 @@
|
||||
onclick="markAllRead()" style="display:none;">Mark all read</button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
<span>Trending</span>
|
||||
</a>
|
||||
@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>
|
||||
<span>Subscriptions</span>
|
||||
</a>
|
||||
|
||||
@@ -136,7 +136,14 @@ $headerSocialMap = [
|
||||
<i class="bi bi-people-fill"></i>
|
||||
{{ number_format($user->subscriber_count) }} {{ Str::plural('subscriber', $user->subscriber_count) }}
|
||||
</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-item">
|
||||
<i class="bi bi-geo-alt-fill"></i>
|
||||
@@ -148,7 +155,7 @@ $headerSocialMap = [
|
||||
<i class="bi bi-calendar3"></i>
|
||||
Joined {{ $user->created_at->format('M Y') }}
|
||||
</span>
|
||||
@if($user->birthday)
|
||||
@if($isOwner && $user->birthday)
|
||||
@php $age = \Carbon\Carbon::parse($user->birthday)->age; @endphp
|
||||
<span class="ch-meta-sep">·</span>
|
||||
<span class="ch-meta-item">
|
||||
@@ -156,13 +163,13 @@ $headerSocialMap = [
|
||||
{{ $age }} yrs
|
||||
</span>
|
||||
@endif
|
||||
@if($user->gender === 'male')
|
||||
@if($isOwner && $user->gender === 'male')
|
||||
<span class="ch-meta-sep">·</span>
|
||||
<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>
|
||||
Male
|
||||
</span>
|
||||
@elseif($user->gender === 'female')
|
||||
@elseif($isOwner && $user->gender === 'female')
|
||||
<span class="ch-meta-sep">·</span>
|
||||
<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>
|
||||
@@ -181,18 +188,25 @@ $headerSocialMap = [
|
||||
</div>
|
||||
@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
|
||||
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
|
||||
<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;"
|
||||
onclick="switchTab('about',document.querySelector('[data-tab=about]'))" title="Click to view horoscope">
|
||||
<div class="ch-horo-sign-part">
|
||||
@@ -361,10 +375,15 @@ $headerSocialMap = [
|
||||
<span class="ch-tab-badge">{{ $videos->count() }}</span>
|
||||
@endif
|
||||
</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)">
|
||||
Shorts
|
||||
@if($shorts->count() > 0)
|
||||
<span class="ch-tab-badge">{{ $shorts->count() }}</span>
|
||||
@endif
|
||||
</button>
|
||||
@endif
|
||||
@if(($playlists && $playlists->count() > 0) || $isOwner)
|
||||
@@ -663,18 +682,18 @@ $headerSocialMap = [
|
||||
<div class="ch-sort-bar" style="margin-bottom:0; flex-shrink:0;">
|
||||
<span class="ch-sort-label">Sort</span>
|
||||
<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>
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'popular']) }}"
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'popular']) . '#videos' }}"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="yt-video-grid" id="chVidGrid">
|
||||
@foreach($videos as $video)
|
||||
@include('components.video-card', ['video' => $video])
|
||||
@include('components.video-card', ['video' => $video, 'hideChannelInfo' => true])
|
||||
@endforeach
|
||||
<div class="ch-vid-no-results" id="chVidNoResults">
|
||||
<i class="bi bi-search"></i>
|
||||
@@ -699,8 +718,15 @@ $headerSocialMap = [
|
||||
</div>
|
||||
|
||||
{{-- ─────────────────────── SHORTS TAB ──────────────── --}}
|
||||
@if($shorts->count() > 0)
|
||||
@if($isOwner || $shorts->count() > 0)
|
||||
<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">
|
||||
@foreach($shorts as $short)
|
||||
<a href="{{ route('videos.show', $short) }}" class="ch-short-card">
|
||||
@@ -722,6 +748,7 @@ $headerSocialMap = [
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user