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]>
67 lines
1.4 KiB
PHP
67 lines
1.4 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
class Comment extends Model
|
|
{
|
|
protected $fillable = [
|
|
'video_id',
|
|
'user_id',
|
|
'parent_id',
|
|
'body',
|
|
'hearted',
|
|
'pinned',
|
|
];
|
|
|
|
protected $casts = [
|
|
'hearted' => 'boolean',
|
|
'pinned' => 'boolean',
|
|
];
|
|
|
|
// Relationships
|
|
public function video()
|
|
{
|
|
return $this->belongsTo(Video::class);
|
|
}
|
|
|
|
public function user()
|
|
{
|
|
return $this->belongsTo(User::class);
|
|
}
|
|
|
|
public function parent()
|
|
{
|
|
return $this->belongsTo(Comment::class, 'parent_id');
|
|
}
|
|
|
|
public function replies()
|
|
{
|
|
return $this->hasMany(Comment::class, 'parent_id')->latest();
|
|
}
|
|
|
|
public function likes()
|
|
{
|
|
return $this->hasMany(CommentLike::class);
|
|
}
|
|
|
|
// Get mentioned users from comment body
|
|
public function getMentionedUsers()
|
|
{
|
|
preg_match_all('/@(\w+)/', $this->body, $matches);
|
|
if (empty($matches[1])) {
|
|
return collect();
|
|
}
|
|
return User::whereIn('username', $matches[1])->get();
|
|
}
|
|
|
|
// Parse mentions and make them clickable
|
|
public function getParsedBodyAttribute()
|
|
{
|
|
$body = e($this->body);
|
|
$body = preg_replace('/@(\w+)/', '<a href="/channel/$1" class="user-mention">@$1</a>', $body);
|
|
return $body;
|
|
}
|
|
}
|