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
@@ -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 ──────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user