diff --git a/app/Http/Controllers/CommentController.php b/app/Http/Controllers/CommentController.php index b51ca21..2f5cc7b 100644 --- a/app/Http/Controllers/CommentController.php +++ b/app/Http/Controllers/CommentController.php @@ -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, + ]); + } } diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 73edc0d..80fea93 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -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, ]); } diff --git a/app/Models/Comment.php b/app/Models/Comment.php index 32bbdd3..63df92f 100644 --- a/app/Models/Comment.php +++ b/app/Models/Comment.php @@ -11,6 +11,13 @@ class Comment extends Model 'user_id', 'parent_id', 'body', + 'hearted', + 'pinned', + ]; + + protected $casts = [ + 'hearted' => 'boolean', + 'pinned' => 'boolean', ]; // Relationships diff --git a/app/Models/Video.php b/app/Models/Video.php index 5897017..4410b9a 100644 --- a/app/Models/Video.php +++ b/app/Models/Video.php @@ -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() { diff --git a/database/migrations/2026_10_21_000001_create_video_dislikes_table.php b/database/migrations/2026_10_21_000001_create_video_dislikes_table.php new file mode 100644 index 0000000..fff266f --- /dev/null +++ b/database/migrations/2026_10_21_000001_create_video_dislikes_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/database/migrations/2026_10_21_000002_add_hearted_and_pinned_to_comments.php b/database/migrations/2026_10_21_000002_add_hearted_and_pinned_to_comments.php new file mode 100644 index 0000000..d2215b2 --- /dev/null +++ b/database/migrations/2026_10_21_000002_add_hearted_and_pinned_to_comments.php @@ -0,0 +1,36 @@ +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']); + }); + } +}; diff --git a/resources/views/components/video-actions.blade.php b/resources/views/components/video-actions.blade.php index fde7e0c..3de8a69 100644 --- a/resources/views/components/video-actions.blade.php +++ b/resources/views/components/video-actions.blade.php @@ -164,19 +164,33 @@ @endauth @auth + {{-- Like + Dislike pair. Mutually exclusive on the server; clicking + one clears the other via the controllers' detach() call. --}} + @else + @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 ────────────────────────────── diff --git a/resources/views/components/video-comments.blade.php b/resources/views/components/video-comments.blade.php index 436c305..b492302 100644 --- a/resources/views/components/video-comments.blade.php +++ b/resources/views/components/video-comments.blade.php @@ -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 ── --}}