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:
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
|
||||
|
||||
Reference in new issue
Block a user