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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,13 @@ class Comment extends Model
|
||||
'user_id',
|
||||
'parent_id',
|
||||
'body',
|
||||
'hearted',
|
||||
'pinned',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'hearted' => 'boolean',
|
||||
'pinned' => 'boolean',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
@@ -53,6 +53,11 @@ class Video extends Model
|
||||
return $this->belongsToMany(User::class, 'video_likes')->withTimestamps();
|
||||
}
|
||||
|
||||
public function dislikes()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'video_dislikes')->withTimestamps();
|
||||
}
|
||||
|
||||
public function viewers()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'video_views')
|
||||
@@ -177,6 +182,20 @@ class Video extends Model
|
||||
return $this->likes()->count();
|
||||
}
|
||||
|
||||
// Dislike helpers — same shape as the like pair above.
|
||||
public function isDislikedBy($user)
|
||||
{
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
return $this->dislikes()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
public function getDislikeCountAttribute()
|
||||
{
|
||||
return $this->dislikes()->count();
|
||||
}
|
||||
|
||||
// Get view count - use direct query to avoid timestamp issues
|
||||
public function getViewCountAttribute()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Dislike counterpart to video_likes.
|
||||
*
|
||||
* One row per (user, video) pair. The unique index on the pair prevents a
|
||||
* user from racking up multiple dislikes against the same video, and the
|
||||
* foreign keys cascade so cleaning up a user or video drops their dislikes
|
||||
* for free.
|
||||
*/
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('video_dislikes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('video_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['video_id', 'user_id']);
|
||||
$table->index('video_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('video_dislikes');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Creator-engagement flags on comments.
|
||||
*
|
||||
* - hearted: the video owner has acknowledged the comment with a red
|
||||
* heart (YouTube's "creator heart"). Visible to everyone.
|
||||
* - pinned: the owner has anchored the comment to the top of the list.
|
||||
* The comment component sorts pinned comments ahead of everything
|
||||
* else, newest-pinned first.
|
||||
*
|
||||
* Both default to false and are indexed only on `pinned` because that's the
|
||||
* one the comments query sorts by.
|
||||
*/
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('comments', function (Blueprint $table) {
|
||||
$table->boolean('hearted')->default(false)->after('body');
|
||||
$table->boolean('pinned')->default(false)->after('hearted');
|
||||
$table->index('pinned');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('comments', function (Blueprint $table) {
|
||||
$table->dropIndex(['pinned']);
|
||||
$table->dropColumn(['hearted', 'pinned']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 ──────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -131,6 +131,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::post('/videos/{video}/like', [UserController::class, 'like'])->name('videos.like');
|
||||
Route::post('/videos/{video}/unlike', [UserController::class, 'unlike'])->name('videos.unlike');
|
||||
Route::post('/videos/{video}/toggle-like', [UserController::class, 'toggleLike'])->name('videos.toggleLike');
|
||||
Route::post('/videos/{video}/toggle-dislike', [UserController::class, 'toggleDislike'])->name('videos.toggleDislike');
|
||||
});
|
||||
|
||||
// Subscribe + notifications — auth but NOT email verification required
|
||||
@@ -155,6 +156,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update');
|
||||
Route::delete('/comments/{comment}', [CommentController::class, 'destroy'])->name('comments.destroy');
|
||||
Route::post('/comments/{comment}/like', [CommentController::class, 'like'])->name('comments.like');
|
||||
Route::post('/comments/{comment}/heart', [CommentController::class, 'heart'])->name('comments.heart');
|
||||
Route::post('/comments/{comment}/pin', [CommentController::class, 'pin'])->name('comments.pin');
|
||||
});
|
||||
|
||||
// User routes
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\VerifyCsrfToken;
|
||||
use App\Models\Comment;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Video dislike toggle + creator heart + creator pin.
|
||||
*
|
||||
* The in-memory DB these tests run against gets the two post-audit migrations
|
||||
* applied automatically by RefreshDatabase, so no manual migrate step is
|
||||
* required to exercise the full feature.
|
||||
*/
|
||||
class DislikeHeartPinTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
putenv('DB_CONNECTION=sqlite');
|
||||
putenv('DB_DATABASE=:memory:');
|
||||
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||
$_ENV['DB_DATABASE'] = ':memory:';
|
||||
|
||||
parent::setUp();
|
||||
|
||||
$this->withoutMiddleware([
|
||||
VerifyCsrfToken::class,
|
||||
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Dislike toggle ──────────────────────────────
|
||||
|
||||
public function test_toggle_dislike_adds_then_removes_the_row(): void
|
||||
{
|
||||
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||
$this->actingAs($viewer);
|
||||
|
||||
$this->postJson(route('videos.toggleDislike', $video))
|
||||
->assertOk()
|
||||
->assertJson(['disliked' => true, 'dislike_count' => 1]);
|
||||
$this->assertDatabaseHas('video_dislikes', ['video_id' => $video->id, 'user_id' => $viewer->id]);
|
||||
|
||||
$this->postJson(route('videos.toggleDislike', $video))
|
||||
->assertOk()
|
||||
->assertJson(['disliked' => false, 'dislike_count' => 0]);
|
||||
$this->assertDatabaseMissing('video_dislikes', ['video_id' => $video->id, 'user_id' => $viewer->id]);
|
||||
}
|
||||
|
||||
public function test_disliking_clears_an_existing_like(): void
|
||||
{
|
||||
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||
$this->actingAs($viewer);
|
||||
$video->likes()->attach($viewer->id);
|
||||
|
||||
$this->postJson(route('videos.toggleDislike', $video))
|
||||
->assertOk()
|
||||
->assertJson(['disliked' => true, 'liked' => false, 'like_count' => 0]);
|
||||
$this->assertDatabaseMissing('video_likes', ['video_id' => $video->id, 'user_id' => $viewer->id]);
|
||||
}
|
||||
|
||||
public function test_liking_clears_an_existing_dislike(): void
|
||||
{
|
||||
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||
$this->actingAs($viewer);
|
||||
$video->dislikes()->attach($viewer->id);
|
||||
|
||||
$this->postJson(route('videos.toggleLike', $video))
|
||||
->assertOk()
|
||||
->assertJson(['liked' => true, 'disliked' => false, 'like_count' => 1, 'dislike_count' => 0]);
|
||||
$this->assertDatabaseMissing('video_dislikes', ['video_id' => $video->id, 'user_id' => $viewer->id]);
|
||||
}
|
||||
|
||||
// ─── Heart ──────────────────────────────────────
|
||||
|
||||
public function test_owner_can_heart_a_comment(): void
|
||||
{
|
||||
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||
$this->actingAs($owner);
|
||||
$comment = Comment::create([
|
||||
'user_id' => $viewer->id,
|
||||
'video_id' => $video->id,
|
||||
'body' => 'nice video!',
|
||||
]);
|
||||
|
||||
$this->postJson(route('comments.heart', $comment))
|
||||
->assertOk()
|
||||
->assertJson(['hearted' => true]);
|
||||
$this->assertEquals(true, $comment->fresh()->hearted);
|
||||
|
||||
$this->postJson(route('comments.heart', $comment))
|
||||
->assertOk()
|
||||
->assertJson(['hearted' => false]);
|
||||
}
|
||||
|
||||
public function test_non_owner_cannot_heart_a_comment(): void
|
||||
{
|
||||
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||
$comment = Comment::create([
|
||||
'user_id' => $viewer->id,
|
||||
'video_id' => $video->id,
|
||||
'body' => 'nice video!',
|
||||
]);
|
||||
$this->actingAs($viewer);
|
||||
|
||||
$this->postJson(route('comments.heart', $comment))->assertStatus(403);
|
||||
$this->assertEquals(false, $comment->fresh()->hearted);
|
||||
}
|
||||
|
||||
// ─── Pin ────────────────────────────────────────
|
||||
|
||||
public function test_owner_pins_at_most_one_comment_at_a_time(): void
|
||||
{
|
||||
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||
$this->actingAs($owner);
|
||||
$a = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'A']);
|
||||
$b = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'B']);
|
||||
|
||||
$this->postJson(route('comments.pin', $a))->assertOk()->assertJson(['pinned' => true]);
|
||||
$this->assertTrue((bool) $a->fresh()->pinned);
|
||||
$this->assertFalse((bool) $b->fresh()->pinned);
|
||||
|
||||
// Pinning B must unpin A.
|
||||
$this->postJson(route('comments.pin', $b))->assertOk()->assertJson(['pinned' => true]);
|
||||
$this->assertFalse((bool) $a->fresh()->pinned);
|
||||
$this->assertTrue((bool) $b->fresh()->pinned);
|
||||
}
|
||||
|
||||
public function test_cannot_pin_a_reply(): void
|
||||
{
|
||||
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||
$this->actingAs($owner);
|
||||
$parent = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'top']);
|
||||
$reply = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'parent_id' => $parent->id, 'body' => 'r']);
|
||||
|
||||
$this->postJson(route('comments.pin', $reply))->assertStatus(422);
|
||||
$this->assertFalse((bool) $reply->fresh()->pinned);
|
||||
}
|
||||
|
||||
public function test_non_owner_cannot_pin_a_comment(): void
|
||||
{
|
||||
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||
$this->actingAs($viewer);
|
||||
$comment = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'self']);
|
||||
|
||||
$this->postJson(route('comments.pin', $comment))->assertStatus(403);
|
||||
}
|
||||
|
||||
// ─── Pinned comments sort first in the component ──
|
||||
|
||||
public function test_pinned_comments_render_before_newer_unpinned_ones(): void
|
||||
{
|
||||
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||
$this->actingAs($owner);
|
||||
|
||||
$pinned = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'OLDPINNED', 'pinned' => true]);
|
||||
// Date it older than the second one so sort would normally send it to the bottom.
|
||||
$pinned->created_at = now()->subDays(5); $pinned->save();
|
||||
Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'NEWFRESH']);
|
||||
|
||||
$html = view('components.video-comments', ['video' => $video])->render();
|
||||
// Match on the unique comment-id markup so count/header text can't
|
||||
// interfere with the order check.
|
||||
$pOld = strpos($html, 'id="comment-' . $pinned->id . '"');
|
||||
$pNew = strpos($html, 'id="comment-' . ($pinned->id + 1) . '"');
|
||||
|
||||
$this->assertNotFalse($pOld);
|
||||
$this->assertNotFalse($pNew);
|
||||
$this->assertLessThan($pNew, $pOld, 'Pinned comment must render before a newer unpinned one.');
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────
|
||||
|
||||
private function makeOwnerViewerVideo(): array
|
||||
{
|
||||
$owner = User::factory()->create(['username' => 'own-'.substr(bin2hex(random_bytes(4)), 0, 6)]);
|
||||
$viewer = User::factory()->create(['username' => 'vwr-'.substr(bin2hex(random_bytes(4)), 0, 6)]);
|
||||
$video = Video::create([
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'test',
|
||||
'description' => '',
|
||||
'filename' => 'users/x/videos/y/video.mp4',
|
||||
'path' => 'users/x/videos/y/video.mp4',
|
||||
'type' => 'generic',
|
||||
'visibility' => 'public',
|
||||
'status' => 'ready',
|
||||
'share_token' => bin2hex(random_bytes(8)),
|
||||
]);
|
||||
return [$owner, $viewer, $video];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user