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

@@ -164,19 +164,33 @@
@endauth
@auth
{{-- Like + Dislike pair. Mutually exclusive on the server; clicking
one clears the other via the controllers' detach() call. --}}
<button type="button"
class="action-btn desktop-action like-toggle-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}"
data-video-id="{{ $video->id }}"
data-toggle-url="{{ route('videos.toggleLike', $video) }}"
data-liked="{{ $video->isLikedBy(Auth::user()) ? 'true' : 'false' }}">
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
data-liked="{{ $video->isLikedBy(Auth::user()) ? 'true' : 'false' }}"
aria-label="{{ $video->isLikedBy(Auth::user()) ? 'Remove like' : 'Like' }}">
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}" aria-hidden="true"></i>
<span class="like-count-label">{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}</span>
</button>
<button type="button"
class="action-btn desktop-action dislike-toggle-btn {{ $video->isDislikedBy(Auth::user()) ? 'disliked' : '' }}"
data-video-id="{{ $video->id }}"
data-toggle-url="{{ route('videos.toggleDislike', $video) }}"
data-disliked="{{ $video->isDislikedBy(Auth::user()) ? 'true' : 'false' }}"
aria-label="{{ $video->isDislikedBy(Auth::user()) ? 'Remove dislike' : 'Dislike' }}">
<i class="bi {{ $video->isDislikedBy(Auth::user()) ? 'bi-hand-thumbs-down-fill' : 'bi-hand-thumbs-down' }}" aria-hidden="true"></i>
</button>
@else
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action">
<i class="bi bi-hand-thumbs-up"></i>
<i class="bi bi-hand-thumbs-up" aria-hidden="true"></i>
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
</button>
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action" aria-label="Dislike">
<i class="bi bi-hand-thumbs-down" aria-hidden="true"></i>
</button>
@endauth
@if ($video->isShareable())
@@ -451,6 +465,46 @@
if (typeof toast === 'function') toast('Could not update like. Try again.', 'error');
});
});
// ── Dislike button ─────────────────────────────────────────────
// Dislike is mutually exclusive with like on the server side; whenever
// the server returns a new state, we reconcile BOTH buttons in one
// sweep so the pair always reflects the authoritative truth.
document.addEventListener('click', function (e) {
var btn = e.target.closest('.dislike-toggle-btn');
if (!btn) return;
var url = btn.dataset.toggleUrl;
var videoId = btn.dataset.videoId;
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' },
})
.then(function (r) { return r.json(); })
.then(function (data) {
// Reconcile dislike pair.
document.querySelectorAll('.dislike-toggle-btn[data-video-id="' + videoId + '"]').forEach(function (b) {
var icon = b.querySelector('i');
b.dataset.disliked = data.disliked ? 'true' : 'false';
b.classList.toggle('disliked', data.disliked);
if (icon) icon.className = 'bi ' + (data.disliked ? 'bi-hand-thumbs-down-fill' : 'bi-hand-thumbs-down');
b.setAttribute('aria-label', data.disliked ? 'Remove dislike' : 'Dislike');
});
// Reconcile like pair in parallel — may have flipped off.
document.querySelectorAll('.like-toggle-btn[data-video-id="' + videoId + '"]').forEach(function (b) {
var icon = b.querySelector('i');
var label = b.querySelector('.like-count-label');
b.dataset.liked = data.liked ? 'true' : 'false';
b.classList.toggle('liked', data.liked);
if (icon) icon.className = 'bi ' + (data.liked ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up');
if (label) label.textContent = data.like_count > 0 ? data.like_count.toLocaleString() : 'Like';
});
})
.catch(function () {
if (typeof toast === 'function') toast('Could not update dislike. Try again.', 'error');
});
});
})();
// ── Slideshow video generation progress ──────────────────────────────