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
@@ -206,4 +206,56 @@ class CommentController extends Controller
|
||||
|
||||
return response()->json(['liked' => $liked, 'count' => $count]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the "creator heart" on a comment. Only the video owner can
|
||||
* drop a heart; anyone else gets a 403.
|
||||
*/
|
||||
public function heart(Comment $comment)
|
||||
{
|
||||
$video = $comment->video;
|
||||
if (! $video || $video->user_id !== Auth::id()) {
|
||||
return response()->json(['error' => 'Only the video owner can heart a comment.'], 403);
|
||||
}
|
||||
|
||||
$comment->hearted = ! $comment->hearted;
|
||||
$comment->save();
|
||||
|
||||
return response()->json([
|
||||
'hearted' => (bool) $comment->hearted,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle "pinned" on a top-level comment. Only the video owner can
|
||||
* pin, and pinned comments sort ahead of everything else in the list.
|
||||
* At most one comment is pinned at a time — pinning a new one unpins
|
||||
* the previous.
|
||||
*/
|
||||
public function pin(Comment $comment)
|
||||
{
|
||||
$video = $comment->video;
|
||||
if (! $video || $video->user_id !== Auth::id()) {
|
||||
return response()->json(['error' => 'Only the video owner can pin a comment.'], 403);
|
||||
}
|
||||
if ($comment->parent_id) {
|
||||
return response()->json(['error' => 'Only top-level comments can be pinned.'], 422);
|
||||
}
|
||||
|
||||
$wasPinned = (bool) $comment->pinned;
|
||||
if (! $wasPinned) {
|
||||
// Unpin any previously-pinned comment on this video first so
|
||||
// the UI invariant "at most one pinned" is maintained at the
|
||||
// DB level, not just in the view.
|
||||
Comment::where('video_id', $video->id)
|
||||
->where('pinned', true)
|
||||
->update(['pinned' => false]);
|
||||
}
|
||||
$comment->pinned = ! $wasPinned;
|
||||
$comment->save();
|
||||
|
||||
return response()->json([
|
||||
'pinned' => (bool) $comment->pinned,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user