feat(engagement): video dislike + creator heart + creator pin

Three features that depend on the two additive migrations shipped in
the same commit (safe defaults, cascading foreign keys, no data loss
possible).

## Dislike button
- New video_dislikes table (migration 2026_10_21_000001) with the same
  shape as video_likes.
- Video model: dislikes() relation + isDislikedBy() / getDislikeCount
  Attribute() helpers mirroring the like pair.
- UserController@toggleDislike: endpoint behind POST
  /videos/{video}/toggle-dislike. Mutually exclusive with like — the
  toggle*Like / toggle*Dislike controllers detach the opposite state
  so a user is in exactly one of {liked, disliked, neither}. YouTube
  parity.
- video-actions component: a second thumbs-down button next to the
  existing like. Both reconcile in parallel on either response so the
  UI pair always reflects the authoritative server state.

## Creator heart + pin
- comments table gains hearted + pinned boolean columns (migration
  2026_10_21_000002, both default false, pinned is indexed).
- CommentController@heart + @pin: 403 for anyone but the video owner;
  pin() also rejects nested replies (422) and auto-unpins the
  previously-pinned top-level comment so the "at most one pinned"
  invariant is enforced at the DB level.
- Comment component:
    - Pinned comments render ahead of everything else; reorder()
      clears the comments() relation's default ->latest() so pinned
      DESC becomes the PRIMARY sort instead of a tiebreaker.
    - Creator sees heart + pin toggle buttons in the action bar.
    - Hearted comments carry a small red heart badge in the meta row.
    - Pinned comments carry a "Pinned by {creator}" header and a
      red left accent so they're visually distinct from the stream.

## Tests (9 new, 63 total)
- Dislike toggle adds/removes the row.
- Disliking clears an existing like and vice versa.
- Owner can heart; non-owner gets 403.
- Only one comment pinned at a time; replies can't be pinned (422);
  non-owner gets 403.
- Pinned comments render before newer unpinned ones.

## Deploy note
Running the migrations against the live DB needs:
    php artisan migrate

The two migration files are additive and reversible via `migrate:rollback`.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-10-21 12:18:17 +03:00
1 parent 73de5bc97e
commit 4d9bc0f501
10 files changed
+570 -8

No files matched your search

@@ -10,7 +10,17 @@
// user's nationality. Batched so the comments list stays a single query.
$commenterIds = $video->comments()->pluck('user_id')->filter()->unique()->values()->all();
$commenterCountry = \App\Models\User::topCountriesFor($commenterIds);
$topComments = $video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get();
// Pinned comments bubble to the top of the list regardless of recency,
// then everything else sorts newest-first. reorder() wipes the
// ->latest() default the comments() relation already applied, so
// pinned DESC becomes the PRIMARY sort instead of a tiebreaker.
$topComments = $video->comments()
->whereNull('parent_id')
->with('user', 'replies.user')
->reorder()
->orderByDesc('pinned')
->latest()
->get();
$topLevelCount = $topComments->count();
$replyCount = $topComments->sum(fn($c) => $c->replies->count());
@@ -105,8 +115,16 @@
{{-- ── Comments list ── --}}
<div id="ytcList">
@forelse($topComments as $comment)
@php $isOwn = $comment->user_id === $currentUserId; @endphp
<div class="ytc-comment" id="comment-{{ $comment->id }}">
@php
$isOwn = $comment->user_id === $currentUserId;
$isVideoOwner = $currentUserId === $video->user_id;
@endphp
<div class="ytc-comment{{ $comment->pinned ? ' ytc-pinned' : '' }}" id="comment-{{ $comment->id }}">
@if($comment->pinned)
<div class="ytc-pinned-badge" aria-label="Pinned by the creator">
<i class="bi bi-pin-fill" aria-hidden="true"></i> Pinned by {{ $video->user->name ?? 'the creator' }}
</div>
@endif
<a href="{{ $comment->user ? route('channel', $comment->user->channel) : '#' }}" class="ytc-avatar-link">
<img src="{{ ytcAvatar($comment->user) }}" class="ytc-avatar" alt="{{ $comment->user->name ?? 'User' }}">
</a>
@@ -119,6 +137,13 @@
href="?lc={{ $comment->id }}#comment-{{ $comment->id }}"
title="Copy link to this comment"
aria-label="Permalink">{{ ytcTime($comment->created_at) }}</a>
@if($comment->hearted)
<span class="ytc-heart-badge"
aria-label="Hearted by the creator"
title="Hearted by {{ $video->user->name ?? 'the creator' }}">
<i class="bi bi-heart-fill" aria-hidden="true"></i>
</span>
@endif
</div>
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $comment->body }}</div>
@@ -149,6 +174,23 @@
<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
{{-- Creator-only heart + pin toggles. The server
rejects anyone else, but hiding the buttons
keeps the UI uncluttered for regular viewers. --}}
@if($isVideoOwner)
<button class="ytc-creator-btn ytc-heart-toggle{{ $comment->hearted ? ' active' : '' }}"
data-comment-id="{{ $comment->id }}"
aria-pressed="{{ $comment->hearted ? 'true' : 'false' }}"
title="{{ $comment->hearted ? 'Remove heart' : 'Heart this comment' }}">
<i class="bi {{ $comment->hearted ? 'bi-heart-fill' : 'bi-heart' }}" aria-hidden="true"></i>
</button>
<button class="ytc-creator-btn ytc-pin-toggle{{ $comment->pinned ? ' active' : '' }}"
data-comment-id="{{ $comment->id }}"
aria-pressed="{{ $comment->pinned ? 'true' : 'false' }}"
title="{{ $comment->pinned ? 'Unpin' : 'Pin to top' }}">
<i class="bi {{ $comment->pinned ? 'bi-pin-fill' : 'bi-pin' }}" aria-hidden="true"></i>
</button>
@endif
<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="{{ $comment->id }}">Reply</button>
@endauth
@if($llmEnabled)
@@ -328,6 +370,49 @@
margin-left: 4px;
}
/* ── Creator engagement (heart + pin) ── */
.ytc-pinned-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 4px;
}
.ytc-pinned-badge i { color: #ef4444; }
.ytc-comment.ytc-pinned {
background: rgba(239, 68, 68, 0.03);
border-left: 2px solid rgba(239, 68, 68, 0.5);
padding-left: 10px;
margin-left: -12px;
}
.ytc-heart-badge {
display: inline-flex;
align-items: center;
margin-left: 4px;
color: #ef4444;
font-size: 13px;
}
.ytc-creator-btn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 15px;
cursor: pointer;
padding: 4px;
border-radius: 50%;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background .15s, color .15s;
}
.ytc-creator-btn:hover { background: rgba(255,255,255,.08); color: var(--text-primary); }
.ytc-creator-btn.active { color: #ef4444; }
.ytc-creator-btn.active:hover { background: rgba(239, 68, 68, 0.15); }
/* Comment permalink highlight — brief glow on the targeted row when a
?lc=<id> link lands on this page. Fades on its own after ~2.8s. */
.ytc-comment.ytc-highlight,
@@ -1081,6 +1166,53 @@ document.getElementById('ytcList')?.addEventListener('click', e => {
const db = e.target.closest('.ytc-dislike-btn');
if (db) { e.stopPropagation(); toggleDislike(db); return; }
// Creator heart — owner-only, server rejects anyone else
const hb = e.target.closest('.ytc-heart-toggle');
if (hb) {
e.stopPropagation();
const id = hb.dataset.commentId;
fetch('/comments/' + id + '/heart', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' },
}).then(r => r.json()).then(d => {
if (d.hearted !== undefined) {
hb.classList.toggle('active', d.hearted);
hb.setAttribute('aria-pressed', d.hearted ? 'true' : 'false');
hb.querySelector('i').className = 'bi ' + (d.hearted ? 'bi-heart-fill' : 'bi-heart');
// Also toggle the visible heart badge in the comment meta.
const meta = hb.closest('.ytc-comment').querySelector('.ytc-meta');
let badge = meta?.querySelector('.ytc-heart-badge');
if (d.hearted && !badge && meta) {
badge = document.createElement('span');
badge.className = 'ytc-heart-badge';
badge.innerHTML = '<i class="bi bi-heart-fill" aria-hidden="true"></i>';
meta.appendChild(badge);
} else if (!d.hearted && badge) {
badge.remove();
}
}
}).catch(() => {});
return;
}
// Creator pin — owner-only, pins top-level comments to the list head
const pb = e.target.closest('.ytc-pin-toggle');
if (pb) {
e.stopPropagation();
const id = pb.dataset.commentId;
fetch('/comments/' + id + '/pin', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' },
}).then(r => r.json()).then(d => {
if (d.pinned !== undefined) {
// Reload to re-sort — pin changes the order of top-level
// comments and the DOM move is cleaner server-side than
// trying to shuffle the live list.
location.reload();
}
}).catch(() => {});
return;
}
});
// Translate — bound at `document` level once, dispatches to the LATEST