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 <noreply@anthropic.com>
This commit is contained in:
Mark
2026-05-30 12:04:24 +03:00
co-authored by Claude Sonnet 4.6
parent 3083071a6c
commit 14047dfe37
12 changed files with 608 additions and 2 deletions
+197
View File
@@ -315,6 +315,127 @@
</div>
@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
<div class="mt-10 pt-8 border-t border-white/5" id="comments"
x-data="commentsWidget({{ Js::from($commentsData) }}, '{{ route('games.comments.store', $game) }}', {{ auth()->id() ?? 'null' }})">
<h2 class="text-lg font-semibold text-white mb-4">Discussion</h2>
<div class="max-w-3xl">
{{-- New comment form (auth only) --}}
@auth
<div class="mb-6 bg-[#16161d] border border-white/10 rounded-lg p-4">
<textarea x-model="body" rows="3"
placeholder="Share your thoughts…"
class="w-full bg-[#0f0f13] border border-white/10 rounded-lg px-4 py-3 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 resize-none transition mb-3"></textarea>
<div class="flex items-center justify-between">
<span class="text-xs text-slate-600" x-text="body.length + ' / 1000'"></span>
<button @click="submitComment()"
:disabled="!body.trim() || submitting"
class="px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-semibold rounded-lg transition-colors">
Post Comment
</button>
</div>
</div>
@else
<div class="mb-6 bg-[#16161d] border border-white/10 rounded-lg p-4 text-center">
<p class="text-slate-400 text-sm">
<a href="{{ route('login') }}" class="text-purple-400 hover:text-purple-300 transition-colors">Log in</a>
to join the discussion.
</p>
</div>
@endauth
{{-- Comment list --}}
<div class="space-y-4">
<template x-if="comments.length === 0">
<p class="text-slate-500 text-sm">No comments yet. Be the first!</p>
</template>
<template x-for="comment in comments" :key="comment.id">
<div class="bg-[#16161d] rounded-lg p-4 border border-white/5">
{{-- Comment header --}}
<div class="flex items-start justify-between gap-2 mb-2">
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-full bg-purple-700 flex items-center justify-center text-xs font-bold text-white flex-shrink-0"
x-text="comment.user.name.charAt(0).toUpperCase()"></div>
<span class="text-sm font-medium text-slate-300" x-text="comment.user.username || comment.user.name"></span>
<span class="text-xs text-slate-600" x-text="comment.created_at"></span>
</div>
<div class="flex items-center gap-2">
@auth
<button x-show="comment.user_id === authUserId"
@click="deleteComment(comment)"
class="text-xs text-red-500 hover:text-red-400 transition-colors">Delete</button>
@endauth
</div>
</div>
{{-- Body --}}
<p class="text-slate-400 text-sm leading-relaxed mb-3" x-text="comment.body"></p>
{{-- Replies --}}
<div class="ml-6 space-y-3 mb-3">
<template x-for="reply in comment.replies" :key="reply.id">
<div class="bg-[#0f0f13] rounded-lg p-3 border border-white/5">
<div class="flex items-start justify-between gap-2 mb-1">
<div class="flex items-center gap-2">
<div class="w-5 h-5 rounded-full bg-purple-800 flex items-center justify-center text-xs font-bold text-white flex-shrink-0"
x-text="reply.user.name.charAt(0).toUpperCase()"></div>
<span class="text-xs font-medium text-slate-300" x-text="reply.user.username || reply.user.name"></span>
<span class="text-xs text-slate-600" x-text="reply.created_at"></span>
</div>
@auth
<button x-show="reply.user_id === authUserId"
@click="deleteReply(reply, comment)"
class="text-xs text-red-500 hover:text-red-400 transition-colors">Delete</button>
@endauth
</div>
<p class="text-slate-400 text-xs leading-relaxed" x-text="reply.body"></p>
</div>
</template>
</div>
{{-- Reply toggle --}}
@auth
<div>
<button @click="comment.showReply = !comment.showReply"
class="text-xs text-slate-500 hover:text-purple-400 transition-colors">
<span x-text="comment.showReply ? 'Cancel' : 'Reply'"></span>
</button>
<div x-show="comment.showReply" x-transition class="mt-2">
<textarea x-model="comment.replyBody" rows="2"
placeholder="Write a reply…"
class="w-full bg-[#0f0f13] border border-white/10 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 resize-none transition mb-2"></textarea>
<button @click="submitReply(comment)"
:disabled="!comment.replyBody?.trim()"
class="px-3 py-1.5 bg-purple-600 hover:bg-purple-700 disabled:opacity-40 disabled:cursor-not-allowed text-white text-xs font-semibold rounded-lg transition-colors">
Post Reply
</button>
</div>
</div>
@endauth
</div>
</template>
</div>
</div>
</div>
{{-- Reviews --}}
<div class="mt-10 pt-8 border-t border-white/5" id="reviews">
<h2 class="text-lg font-semibold text-white mb-4">Reviews</h2>
@@ -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,