Compare commits
4
Commits
5aef41201a
...
73de5bc97e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73de5bc97e | ||
|
|
341e94a4f8 | ||
|
|
574c9581bb | ||
|
|
22d6b1507d |
No files matched your search
@@ -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':
|
||||
|
||||
@@ -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) ---- --}}
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user