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

+30 -2
View File
@@ -453,14 +453,42 @@ class UserController extends Controller
} else {
$video->likes()->attach($user->id);
$liked = true;
// Toggling to like clears any existing dislike — mirrors YT's
// behavior where a video can be liked OR disliked, not both.
$video->dislikes()->detach($user->id);
if ($video->user_id && $video->user_id !== $user->id) {
try { $video->user->notify(new VideoLikedNotification($video, $user)); } catch (\Throwable) {}
}
}
return response()->json([
'liked' => $liked,
'like_count' => $video->like_count,
'liked' => $liked,
'disliked' => false, // was cleared above if we just liked, otherwise toggle didn't touch it
'like_count' => $video->like_count,
'dislike_count' => $video->dislike_count,
]);
}
// Toggle dislike (API). Mutually exclusive with the like state.
public function toggleDislike(Video $video)
{
$user = Auth::user();
if ($video->isDislikedBy($user)) {
$video->dislikes()->detach($user->id);
$disliked = false;
} else {
$video->dislikes()->attach($user->id);
$disliked = true;
// Clear any existing like.
$video->likes()->detach($user->id);
}
return response()->json([
'disliked' => $disliked,
'liked' => $video->isLikedBy($user), // guaranteed false here when disliked just became true
'like_count' => $video->like_count,
'dislike_count' => $video->dislike_count,
]);
}