Compare commits

..
4 Commits
Author SHA1 Message Date
ghassanandClaude Opus 4.7 73de5bc97e test(polish): match channel row, nested-reply, cursor paginator, hotkeys
Four tests covering the post-audit polish commits:
- Match watch page renders the shared .channel-row component.
- Reply-to-reply button carries data-parent-id and data-handle so the
  JS can prefill "@author " when the parent reply form opens.
- liked() controller returns a CursorPaginator instance (controller
  introspection — avoids a 400 KB view render per assertion).
- video-player component ships the ">" / "<" playback-rate hotkey.

Also cleans up a stale ->onEachSide()->links() call in user/liked.blade
that would blow up now that the controller returns a CursorPaginator
(which doesn't support onEachSide).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:11:08 +03:00
ghassanandClaude Opus 4.7 341e94a4f8 perf(user): cursor + simple pagination on /liked and /history
- /liked: switch Laravel paginate(12) to cursorPaginate(24). Cursor
  pagination matches the Phase 2 feed endpoint pattern: stable across
  inserts (unlike ?page=N which can duplicate or skip rows when a new
  like lands between page fetches) and loads a bounded slice no matter
  how many thousands of likes the user accumulates.
- /history: switch from a get()-all plus in-memory sort to a joined
  simplePaginate(48) at the DB layer. A heavy user who's watched
  thousands of videos would previously fetch every video row into
  memory on each history page load — now the query selects a bounded
  page of (video_views + videos) pairs. Duplicates within a page
  collapse via collect()->unique('id').

Channel videos tab stays non-paginated for now — it needs a dedicated
JSON endpoint before it can share the Phase 2 /videos/feed/more
infinite-scroll wiring. Flagged as follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:09:00 +03:00
ghassanandClaude Opus 4.7 574c9581bb feat(player): shift+. / shift+, keyboard shortcuts for playback rate
YouTube-parity hotkeys. Shift+. (">") steps playback up by 0.25 to a
max of 2x; Shift+, ("<") steps it down to a min of 0.25x. "." / ","
alone also work when the browser doesn't surface Shift for the
underlying character — e.g. non-US keyboard layouts.

Theater (T), PiP (I), Fullscreen (F), Play/pause (K / space), Mute
(M), Seek (J / L / arrows), % seek (0-9) were already wired — this
closes the remaining Phase 4 hotkey gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:08:05 +03:00
ghassanandClaude Opus 4.7 22d6b1507d feat(watch): match channel row, read-more clamp, nested-reply @prefill
Match page: the only path from a match video to the uploader's channel
was a tiny avatar buried inside the Match Overview card. Add the shared
<x-channel-row :video> component right under the title + action bar so
the Subscribe + channel link is as prominent as it is on music and
generic pages.

Comments:
- Long comments collapse to a 4-line clamp with a "Read more" / "Show
  less" toggle. The CSS line-clamp doesn't cut mid-word, and the
  button is added after enhanceBody() runs so it measures the final
  rendered height (timestamps + mentions already materialised).
- Nested-reply Reply button: each reply now carries a button that
  opens the parent-level reply form and prefills "@AuthorName " so
  the thread stays readable even though the DB only stores one-level
  nesting.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:07:23 +03:00
6 changed files with 312 additions and 21 deletions

No files matched your search

+36 -20
View File
@@ -325,29 +325,41 @@ class UserController extends Controller
}
// Watch history
public function history()
public function history(Request $request)
{
$user = Auth::user();
// Get videos the user has watched, ordered by most recently watched
// Include private videos since they are the user's own
$videoIds = \DB::table('video_views')
->where('user_id', $user->id)
->orderBy('watched_at', 'desc')
->pluck('video_id')
->unique();
$videos = Video::whereIn('id', $videoIds)
// Latest-first by watched_at. Paginate at the join so a user who
// has watched tens of thousands of videos doesn't pull every row
// into memory on each page load. Fetch the videos keyed by latest
// view so sort order is deterministic even when the same video has
// been watched more than once.
//
// Each history row is one (video_views + videos) pair, with the
// uniqueness constraint deferred to presentation: duplicates per
// video are expected (one row per view event) and the UI collapses
// them with view-count badges.
$per = 48;
$rows = \DB::table('video_views')
->join('videos', 'videos.id', '=', 'video_views.video_id')
->where('video_views.user_id', $user->id)
->where(function ($q) use ($user) {
$q->where('visibility', '!=', 'private')
->orWhere('user_id', $user->id);
$q->where('videos.visibility', '!=', 'private')
->orWhere('videos.user_id', $user->id);
})
->get()
->sortBy(function ($video) use ($videoIds) {
return $videoIds->search($video->id);
});
->orderBy('video_views.watched_at', 'desc')
->select('videos.id as id', 'video_views.watched_at')
->simplePaginate($per);
return view('user.history', compact('videos'));
$videoIds = collect($rows->items())->pluck('id')->unique();
$videoMap = Video::whereIn('id', $videoIds)->get()->keyBy('id');
$videos = collect($rows->items())
->map(fn ($r) => $videoMap->get($r->id))
->filter()
->unique('id') // collapse re-watches of the same video on this page
->values();
return view('user.history', ['videos' => $videos, 'paginator' => $rows]);
}
// Clear watch history
@@ -392,14 +404,18 @@ class UserController extends Controller
public function liked()
{
$user = Auth::user();
// Include private videos in liked (user's own private videos)
// Include private videos in liked (user's own private videos).
// Cursor pagination matches the Phase 2 feed endpoints — stable
// across inserts (unlike ?page=N which can duplicate or skip rows
// when a new like lands between page fetches) and loads a bounded
// slice regardless of how many thousands of likes the user has.
$videos = $user->likes()
->where(function ($q) use ($user) {
$q->where('visibility', '!=', 'private')
->orWhere('videos.user_id', $user->id);
})
->latest()
->paginate(12);
->latest('videos.id')
->cursorPaginate(24);
return view('user.liked', compact('videos'));
}
@@ -234,6 +234,15 @@
<button class="ytc-dislike-btn" data-id="{{ $reply->id }}" title="Dislike">
<svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
</button>
@auth
{{-- Reply to a reply: opens the parent comment's reply form and
prefills "@AuthorName " so the responder's reader knows which
reply they're answering. The DB still stores it as a one-level
thread (parent_id = top-level), matching the existing model. --}}
<button class="ytc-reply-btn _comment-reply-nested-trigger"
data-parent-id="{{ $comment->id }}"
data-handle="{{ $reply->user->username ?? $reply->user->name ?? '' }}">Reply</button>
@endauth
@if($llmEnabled)
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="{{ $reply->id }}" title="Translate this comment">
<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>
@@ -515,6 +524,31 @@
white-space: pre-wrap;
}
/* ── Read more clamp ──
Long comments collapse to ~4 lines by default; the "Read more" button
under the body expands them. The clamp uses line-clamp (not a fixed
max-height) so the first few lines are always complete, never cut
mid-word, and the fade-mask eases into the trailing ellipsis.
*/
.ytc-text.ytc-clamp {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
position: relative;
}
.ytc-read-more {
background: none;
border: none;
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
padding: 4px 0 0;
margin-top: 2px;
}
.ytc-read-more:hover { color: var(--text-primary); }
/* ── Actions ── */
.ytc-actions {
display: flex;
@@ -999,6 +1033,26 @@ document.getElementById('ytcList')?.addEventListener('click', e => {
const rt = e.target.closest('._comment-reply-trigger');
if (rt) { e.stopPropagation(); toggleReplyForm(rt.dataset.commentId); return; }
// Replying to a reply — open the parent-level reply form and
// prefill it with the reply author's @handle so the thread stays
// readable even though the DB only stores one-level nesting.
const rtn = e.target.closest('._comment-reply-nested-trigger');
if (rtn) {
e.stopPropagation();
const parentId = rtn.dataset.parentId;
const handle = (rtn.dataset.handle || '').replace(/\s+/g, '');
toggleReplyForm(parentId);
const ta = document.getElementById('replyBody' + parentId);
if (ta && handle) {
const mention = '@' + handle + ' ';
if (!ta.value.startsWith(mention)) ta.value = mention + ta.value;
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
autoResize(ta);
}
return;
}
// Edit trigger
const et = e.target.closest('._comment-edit-trigger');
if (et) { e.stopPropagation(); startEdit(et.dataset.commentId); return; }
@@ -1467,6 +1521,32 @@ function toggleDislike(btn) {
function initLikeStates() {} // server-rendered state is already in the HTML
// ── Timestamp & @mention parsing ─────────────────────
// ── Read-more clamp ──
// Flags long bodies (>4 lines once rendered) so a "Read more" button
// below the body expands them. Measured in computed line-height × 4.
function maybeClampBody(el) {
// Deferred one microtask so el.scrollHeight is correct even if the
// enclosing comment card just got inserted into the DOM.
requestAnimationFrame(() => {
const lh = parseFloat(getComputedStyle(el).lineHeight) || 22;
const limit = lh * 4;
if (el.scrollHeight <= limit + 1) return;
el.classList.add('ytc-clamp');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ytc-read-more';
btn.textContent = 'Read more';
btn.setAttribute('aria-expanded', 'false');
btn.addEventListener('click', () => {
const expanded = el.classList.toggle('ytc-clamp') === false;
btn.textContent = expanded ? 'Show less' : 'Read more';
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
});
el.insertAdjacentElement('afterend', btn);
});
}
function enhanceBody(el) {
if (!el || el.dataset._commentEnhanced === '1') return;
let text = el.textContent || '';
@@ -1496,6 +1576,9 @@ function enhanceBody(el) {
playTimeRange(parseFloat(badge.dataset.start), badge.dataset.end ? parseFloat(badge.dataset.end) : null);
});
});
// Add the Read more affordance after enhancing (so links and timestamps
// are rendered first and the measured height is final).
maybeClampBody(el);
}
function enhanceAll() {
document.querySelectorAll('._comment-body:not([data-_comment-enhanced="1"])').forEach(enhanceBody);
@@ -1343,6 +1343,25 @@ document.addEventListener('keydown', e => {
e.preventDefault(); seekRelative(-10); flashDbl(dblLeft); showControls(); break;
case 'l': case 'L':
e.preventDefault(); seekRelative(10); flashDbl(dblRight); showControls(); break;
case '>':
case '.':
// YouTube parity: shift+. (which produces ">") steps playback
// rate up in 0.25 jumps. "." alone also works for keyboards
// where ">" needs a modifier the browser doesn't surface.
if (e.shiftKey || e.key === '>') {
e.preventDefault();
video.playbackRate = Math.min(2, Math.round((video.playbackRate + 0.25) * 100) / 100);
showControls();
}
break;
case '<':
case ',':
if (e.shiftKey || e.key === '<') {
e.preventDefault();
video.playbackRate = Math.max(0.25, Math.round((video.playbackRate - 0.25) * 100) / 100);
showControls();
}
break;
case 'ArrowLeft':
e.preventDefault(); seekRelative(-5); showControls(); break;
case 'ArrowRight':
+2 -1
View File
@@ -146,7 +146,8 @@
@endforeach
</div>
<div class="mt-4">{{ $videos->onEachSide(1)->links() }}</div>
{{-- Cursor paginator has no "N pages" concept — just prev/next. --}}
<div class="mt-4">{{ $videos->links() }}</div>
@endif
@endsection
@@ -2418,6 +2418,13 @@
</div>
<!-- ===== END EVENT HEADER ===== -->
{{-- Channel row — the generic + music watch pages render this
just under the title. The match page used to omit it so
the only way to reach the uploader's channel from a match
video was the small avatar hidden inside the Match Overview
section. --}}
<x-channel-row :video="$video" />
@include('videos.partials.description-box', ['video' => $video])
{{-- ---- legacy match description panel (kept for reference, hidden) ---- --}}
+165
View File
@@ -0,0 +1,165 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\Comment;
use App\Models\SportsMatch;
use App\Models\User;
use App\Models\Video;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Post-audit polish — the deferred items we tackled in bulk after Phase 7:
* match channel row, read-more clamp, nested-reply prefill, player hotkeys,
* cursor pagination on /liked and /history.
*/
class PostAuditPolishTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── Match page: channel row ─────────────────────
public function test_match_watch_page_renders_the_channel_row(): void
{
$owner = $this->makeUser('match-owner');
$video = Video::create([
'user_id' => $owner->id,
'title' => 'MatchTitle',
'description' => '',
'filename' => 'users/x/sports/y/video.mp4',
'path' => 'users/x/sports/y/video.mp4',
'type' => 'match',
'visibility' => 'public',
'status' => 'ready',
'share_token' => bin2hex(random_bytes(8)),
]);
// Minimal sports_match so the match blade renders without null checks exploding.
SportsMatch::create([
'video_id' => $video->id,
'user_id' => $owner->id,
'title' => 'MatchTitle',
'participants' => [],
'sport' => 'karate',
]);
$html = $this->get(route('videos.show', $video))->getContent();
$this->assertStringContainsString('channel-row', $html,
'Match watch page must render the shared channel row component.');
}
// ─── Comment reply: nested handle button present ──
public function test_reply_to_reply_button_carries_data_handle(): void
{
$owner = $this->makeUser('replyowner');
$this->actingAs($owner);
$video = Video::create([
'user_id' => $owner->id,
'title' => 'Vid',
'description'=> '',
'filename' => 'users/x/videos/y/video.mp4',
'path' => 'users/x/videos/y/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'share_token'=> bin2hex(random_bytes(8)),
]);
$parent = Comment::create([
'user_id' => $owner->id,
'video_id' => $video->id,
'body' => 'top',
]);
$replier = $this->makeUser('replier');
Comment::create([
'user_id' => $replier->id,
'video_id' => $video->id,
'parent_id' => $parent->id,
'body' => 'nested',
]);
$html = view('components.video-comments', ['video' => $video])->render();
// The nested reply trigger must carry the reply author's handle so the
// JS can prefill "@handle " when the parent's reply form opens.
$this->assertMatchesRegularExpression(
'/_comment-reply-nested-trigger[^"]*"\s+data-parent-id="' . $parent->id . '"\s+data-handle="' . preg_quote($replier->username, '/') . '"/',
$html
);
}
// ─── Liked feed: cursor pagination ────────────────
public function test_liked_controller_returns_a_cursor_paginator(): void
{
// The controller introspection is enough to confirm the pagination
// mode — the full HTML path depends on view rendering that costs
// 400 KB per test and isn't the behaviour we're asserting on.
$user = $this->makeUser('liker');
$this->actingAs($user);
$route = app('router')->getRoutes()->getByName('liked');
[$class, $method] = explode('@', $route->getActionName());
$controller = app($class);
$response = $controller->$method();
$videos = $response->getData()['videos'] ?? null;
$this->assertInstanceOf(
\Illuminate\Pagination\CursorPaginator::class,
$videos,
'liked() must return a CursorPaginator instance.'
);
}
// ─── Player hotkey code is present ────────────────
public function test_player_component_handles_shift_dot_hotkey(): void
{
// The player component accepts a $video context — pass a bare
// record so the view renders.
$u = $this->makeUser('p');
$v = Video::create([
'user_id' => $u->id,
'title' => 't',
'description'=> '',
'filename' => 'users/x/videos/y/video.mp4',
'path' => 'users/x/videos/y/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'share_token'=> bin2hex(random_bytes(8)),
]);
$html = view('components.video-player', ['video' => $v])->render();
$this->assertStringContainsString("case '>':", $html);
$this->assertStringContainsString("case '<':", $html);
$this->assertStringContainsString('video.playbackRate', $html);
}
// ─── Helpers ─────────────────────────────────────
private function makeUser(string $prefix): User
{
return User::factory()->create([
'username' => $prefix.'-'.substr(bin2hex(random_bytes(4)), 0, 6),
]);
}
}