From 14047dfe37b34520ffe2212b5faca1b6c8225129 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 30 May 2026 12:02:24 +0300 Subject: [PATCH] Add friend library comparison and game comments features - CompareController: side-by-side library diff with stats (common games, rating diff) - GameCommentController + GameCommentPolicy: threaded comments with replies, auth-gated - GameComment model with top-level scope and self-referential replies relation - Migration: game_comments table with parent_id for threading - games/show: Discussion section with Alpine.js (post, reply, delete) - compare/show: tabbed view (in common, they have, you have) with game cards - users/show: "Compare Libraries" button visible to other authenticated users Co-Authored-By: Claude Sonnet 4.6 --- app/Http/Controllers/CompareController.php | 62 ++++++ .../Controllers/GameCommentController.php | 49 +++++ app/Http/Controllers/GameController.php | 9 +- app/Models/Game.php | 5 + app/Models/GameComment.php | 19 ++ app/Policies/GameCommentPolicy.php | 14 ++ app/Providers/AppServiceProvider.php | 1 + ...5_30_000001_create_game_comments_table.php | 25 +++ resources/views/compare/show.blade.php | 206 ++++++++++++++++++ resources/views/games/show.blade.php | 197 +++++++++++++++++ resources/views/users/show.blade.php | 10 +- routes/web.php | 13 ++ 12 files changed, 608 insertions(+), 2 deletions(-) create mode 100644 app/Http/Controllers/CompareController.php create mode 100644 app/Http/Controllers/GameCommentController.php create mode 100644 app/Models/GameComment.php create mode 100644 app/Policies/GameCommentPolicy.php create mode 100644 database/migrations/2026_05_30_000001_create_game_comments_table.php create mode 100644 resources/views/compare/show.blade.php diff --git a/app/Http/Controllers/CompareController.php b/app/Http/Controllers/CompareController.php new file mode 100644 index 0000000..3bb3756 --- /dev/null +++ b/app/Http/Controllers/CompareController.php @@ -0,0 +1,62 @@ +id() === $user->id) { + return redirect()->route('users.show', $user->username); + } + + $myGames = auth()->user()->library()->with('game')->get()->keyBy('game_id'); + $theirGames = $user->library()->with('game')->get()->keyBy('game_id'); + + $myIds = $myGames->keys()->values(); + $theirIds = $theirGames->keys()->values(); + + $bothIds = $myIds->intersect($theirIds)->values(); + $theyHaveNotYouIds = $theirIds->diff($myIds)->values(); + $youHaveNotThemIds = $myIds->diff($theirIds)->values(); + + $both = $bothIds->map(fn($id) => [ + 'game' => $myGames[$id]->game, + 'myEntry' => $myGames[$id], + 'theirEntry' => $theirGames[$id], + ])->filter(fn($item) => $item['game'] !== null)->values(); + + $theyHaveNotYou = $theyHaveNotYouIds + ->map(fn($id) => $theirGames[$id]) + ->filter(fn($ug) => $ug->game !== null) + ->values(); + + $youHaveNotThem = $youHaveNotThemIds + ->map(fn($id) => $myGames[$id]) + ->filter(fn($ug) => $ug->game !== null) + ->values(); + + // Average rating diff on games both rated + $diffSum = 0; + $diffCount = 0; + foreach ($bothIds as $id) { + $myRating = $myGames[$id]->rating; + $theirRating = $theirGames[$id]->rating; + if ($myRating !== null && $theirRating !== null) { + $diffSum += abs($myRating - $theirRating); + $diffCount++; + } + } + + $stats = [ + 'both_count' => $bothIds->count(), + 'they_not_you' => $theyHaveNotYouIds->count(), + 'you_not_them' => $youHaveNotThemIds->count(), + 'avg_rating_diff' => $diffCount > 0 ? round($diffSum / $diffCount, 1) : null, + ]; + + return view('compare.show', compact('user', 'both', 'theyHaveNotYou', 'youHaveNotThem', 'stats')); + } +} diff --git a/app/Http/Controllers/GameCommentController.php b/app/Http/Controllers/GameCommentController.php new file mode 100644 index 0000000..cda3f17 --- /dev/null +++ b/app/Http/Controllers/GameCommentController.php @@ -0,0 +1,49 @@ +validate([ + 'body' => 'required|string|max:1000', + 'parent_id' => 'nullable|integer|exists:game_comments,id', + ]); + + if (isset($data['parent_id'])) { + $parent = GameComment::findOrFail($data['parent_id']); + abort_if($parent->game_id !== $game->id, 422, 'Parent comment does not belong to this game.'); + } + + $comment = $game->comments()->create([ + 'user_id' => auth()->id(), + 'body' => $data['body'], + 'parent_id' => $data['parent_id'] ?? null, + ]); + + $comment->load('user'); + + return response()->json([ + 'id' => $comment->id, + 'body' => $comment->body, + 'user' => ['name' => $comment->user->name, 'username' => $comment->user->username], + 'user_id' => $comment->user_id, + 'created_at' => $comment->created_at->diffForHumans(), + 'replies_count' => 0, + ], 201); + } + + public function destroy(GameComment $comment): JsonResponse + { + $this->authorize('delete', $comment); + $comment->delete(); + + return response()->json(['deleted' => true]); + } +} diff --git a/app/Http/Controllers/GameController.php b/app/Http/Controllers/GameController.php index f32216f..051f526 100644 --- a/app/Http/Controllers/GameController.php +++ b/app/Http/Controllers/GameController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\Game; +use App\Models\GameComment; use App\Models\Genre; use App\Models\Platform; use Illuminate\Http\Request; @@ -54,7 +55,13 @@ class GameController extends Controller $userLists = auth()->user()->lists()->get(['id', 'title']); } - $response = view('games.show', compact('game', 'userGame', 'userReview', 'userLists')); + $comments = $game->comments()->whereNull('parent_id') + ->with(['user', 'replies.user']) + ->latest() + ->limit(20) + ->get(); + + $response = view('games.show', compact('game', 'userGame', 'userReview', 'userLists', 'comments')); return auth()->check() ? $response : response($response)->header('Cache-Control', 'public, max-age=300'); diff --git a/app/Models/Game.php b/app/Models/Game.php index ede2bc2..5251060 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -62,6 +62,11 @@ class Game extends Model return $this->hasMany(Review::class)->latest(); } + public function comments(): HasMany + { + return $this->hasMany(GameComment::class); + } + public function toSearchableArray(): array { return [ diff --git a/app/Models/GameComment.php b/app/Models/GameComment.php new file mode 100644 index 0000000..c2c6c5c --- /dev/null +++ b/app/Models/GameComment.php @@ -0,0 +1,19 @@ +belongsTo(User::class); } + public function game(): BelongsTo { return $this->belongsTo(Game::class); } + public function replies(): HasMany { return $this->hasMany(GameComment::class, 'parent_id')->with('user')->latest(); } + + public function scopeTopLevel(Builder $query): Builder { return $query->whereNull('parent_id'); } +} diff --git a/app/Policies/GameCommentPolicy.php b/app/Policies/GameCommentPolicy.php new file mode 100644 index 0000000..a217cb3 --- /dev/null +++ b/app/Policies/GameCommentPolicy.php @@ -0,0 +1,14 @@ +id === $comment->user_id; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 0946796..6cc9d94 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -25,5 +25,6 @@ class AppServiceProvider extends ServiceProvider Gate::policy(UserGame::class, UserGamePolicy::class); Gate::policy(\App\Models\Review::class, \App\Policies\ReviewPolicy::class); Gate::policy(\App\Models\UserList::class, \App\Policies\ListPolicy::class); + Gate::policy(\App\Models\GameComment::class, \App\Policies\GameCommentPolicy::class); } } diff --git a/database/migrations/2026_05_30_000001_create_game_comments_table.php b/database/migrations/2026_05_30_000001_create_game_comments_table.php new file mode 100644 index 0000000..478a566 --- /dev/null +++ b/database/migrations/2026_05_30_000001_create_game_comments_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('game_id')->constrained()->cascadeOnDelete(); + $table->foreignId('parent_id')->nullable()->constrained('game_comments')->cascadeOnDelete(); + $table->text('body'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('game_comments'); + } +}; diff --git a/resources/views/compare/show.blade.php b/resources/views/compare/show.blade.php new file mode 100644 index 0000000..a723233 --- /dev/null +++ b/resources/views/compare/show.blade.php @@ -0,0 +1,206 @@ +@extends('layouts.app') + +@section('title', 'Your Library vs ' . ($user->username ?? $user->name) . ' — GameLog') + +@section('content') +
+ + {{-- Header --}} +
+ + ← {{ $user->username ?? $user->name }}'s profile + +

+ Your Library vs {{ $user->username ?? $user->name }}'s Library +

+
+ + {{-- Stats Row --}} +
+
+
{{ $stats['both_count'] }}
+
In Common
+
+
+
{{ $stats['they_not_you'] }}
+
They Have, You Don't
+
+
+
{{ $stats['you_not_them'] }}
+
You Have, They Don't
+
+
+ @if($stats['avg_rating_diff'] !== null) +
{{ $stats['avg_rating_diff'] }}
+
Avg Rating Diff
+ @else +
—
+
Avg Rating Diff
+ @endif +
+
+ + {{-- Tabs --}} +
+ + + +
+ + {{-- Tab: In Common --}} +
+ @if($both->isEmpty()) +
No games in common yet.
+ @else +
+ @foreach($both as $item) + @php $game = $item['game']; @endphp +
+ + @if($game->cover_url) + {{ $game->title }} + @else +
+ + + +
+ @endif +
+
+ + {{ $game->title }} + +
+
+ You + @if($item['myEntry']->rating) + {{ $item['myEntry']->rating }} + @else + — + @endif +
+
+
+ Them + @if($item['theirEntry']->rating) + {{ $item['theirEntry']->rating }} + @else + — + @endif +
+
+
+
+ @endforeach +
+ @endif +
+ + {{-- Tab: They Played, You Haven't --}} +
+ @if($theyHaveNotYou->isEmpty()) +
No games here.
+ @else +
+ @foreach($theyHaveNotYou as $entry) +
+ + @if($entry->game->cover_url) + {{ $entry->game->title }} + @else +
+ + + +
+ @endif +
+
+ + {{ $entry->game->title }} + + @if($entry->rating) +
+ + + + {{ $entry->rating }}/10 +
+ @endif +
+ @csrf + + + +
+
+
+ @endforeach +
+ @endif +
+ + {{-- Tab: You Played, They Haven't --}} +
+ @if($youHaveNotThem->isEmpty()) +
No games here.
+ @else +
+ @foreach($youHaveNotThem as $entry) +
+ + @if($entry->game->cover_url) + {{ $entry->game->title }} + @else +
+ + + +
+ @endif +
+
+ + {{ $entry->game->title }} + + @if($entry->rating) +
+ + + + {{ $entry->rating }}/10 +
+ @endif +
+
+ @endforeach +
+ @endif +
+ +
+@endsection diff --git a/resources/views/games/show.blade.php b/resources/views/games/show.blade.php index 615283e..4f22d53 100644 --- a/resources/views/games/show.blade.php +++ b/resources/views/games/show.blade.php @@ -315,6 +315,127 @@ @endif + {{-- Comments --}} + @php + $commentsData = $comments->map(fn($c) => [ + 'id' => $c->id, + 'body' => $c->body, + 'user' => ['name' => $c->user->name, 'username' => $c->user->username], + 'user_id' => $c->user_id, + 'created_at' => $c->created_at->diffForHumans(), + 'replies' => $c->replies->map(fn($r) => [ + 'id' => $r->id, + 'body' => $r->body, + 'user' => ['name' => $r->user->name, 'username' => $r->user->username], + 'user_id' => $r->user_id, + 'created_at' => $r->created_at->diffForHumans(), + ])->values()->all(), + ])->values()->all(); + @endphp +
+ +

Discussion

+
+ + {{-- New comment form (auth only) --}} + @auth +
+ +
+ + +
+
+ @else +
+

+ Log in + to join the discussion. +

+
+ @endauth + + {{-- Comment list --}} +
+ + +
+ +
+
+ {{-- Reviews --}}

Reviews

@@ -595,6 +716,82 @@ function deleteReview(id) { }).then(r => { if (r.ok) window.location.reload(); }); } +function commentsWidget(initialComments, storeUrl, authUserId) { + return { + comments: initialComments.map(c => ({ ...c, showReply: false, replyBody: '' })), + body: '', + submitting: false, + authUserId, + + async submitComment() { + if (!this.body.trim() || this.submitting) return; + this.submitting = true; + const res = await fetch(storeUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, + 'Accept': 'application/json', + }, + body: JSON.stringify({ body: this.body }), + }); + if (res.ok) { + const comment = await res.json(); + this.comments.unshift({ ...comment, replies: [], showReply: false, replyBody: '' }); + this.body = ''; + } + this.submitting = false; + }, + + async submitReply(parentComment) { + if (!parentComment.replyBody?.trim()) return; + const res = await fetch(storeUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, + 'Accept': 'application/json', + }, + body: JSON.stringify({ body: parentComment.replyBody, parent_id: parentComment.id }), + }); + if (res.ok) { + const reply = await res.json(); + parentComment.replies.push(reply); + parentComment.showReply = false; + parentComment.replyBody = ''; + } + }, + + async deleteComment(comment) { + if (!confirm('Delete this comment?')) return; + const res = await fetch(`/comments/${comment.id}`, { + method: 'DELETE', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, + 'Accept': 'application/json', + }, + }); + if (res.ok) { + this.comments = this.comments.filter(c => c.id !== comment.id); + } + }, + + async deleteReply(reply, parentComment) { + if (!confirm('Delete this reply?')) return; + const res = await fetch(`/comments/${reply.id}`, { + method: 'DELETE', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, + 'Accept': 'application/json', + }, + }); + if (res.ok) { + parentComment.replies = parentComment.replies.filter(r => r.id !== reply.id); + } + }, + }; +} + function addToListWidget(gameId, lists) { return { gameId, diff --git a/resources/views/users/show.blade.php b/resources/views/users/show.blade.php index 11f1c53..02062cc 100644 --- a/resources/views/users/show.blade.php +++ b/resources/views/users/show.blade.php @@ -58,7 +58,7 @@
-
+
@auth @if(auth()->id() !== $user->id)
View Library + @auth + @if(auth()->id() !== $user->id) + + Compare Libraries + + @endif + @endauth
diff --git a/routes/web.php b/routes/web.php index e605951..ca14bcd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,7 @@ name('games.s Route::get('/users/{user:username}', [UserController::class, 'show'])->name('users.show'); Route::get('/users/{user:username}/library', [UserController::class, 'library'])->name('users.library'); +// Compare (auth required) +Route::middleware('auth')->group(function () { + Route::get('/users/{user:username}/compare', [CompareController::class, 'show'])->name('users.compare'); +}); + // Library (auth required) Route::middleware('auth')->group(function () { Route::post('/library', [LibraryController::class, 'store'])->name('library.store'); @@ -35,6 +42,12 @@ Route::middleware('auth')->group(function () { Route::delete('/reviews/{review}', [\App\Http\Controllers\ReviewController::class, 'destroy'])->name('reviews.destroy'); }); +// Comments (auth required) +Route::middleware('auth')->group(function () { + Route::post('/games/{game:slug}/comments', [GameCommentController::class, 'store'])->name('games.comments.store'); + Route::delete('/comments/{comment}', [GameCommentController::class, 'destroy'])->name('comments.destroy'); +}); + // Lists Route::get('/lists', [\App\Http\Controllers\ListController::class, 'index'])->name('lists.index')->middleware('auth'); Route::post('/lists', [\App\Http\Controllers\ListController::class, 'store'])->name('lists.store')->middleware('auth');