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:
co-authored by
Claude Sonnet 4.6
parent
3083071a6c
commit
14047dfe37
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
class CompareController extends Controller
|
||||
{
|
||||
public function show(User $user)
|
||||
{
|
||||
if (auth()->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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Game;
|
||||
use App\Models\GameComment;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class GameCommentController extends Controller
|
||||
{
|
||||
public function store(Request $request, Game $game): JsonResponse
|
||||
{
|
||||
$data = $request->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]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class GameComment extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'game_id', 'parent_id', 'body'];
|
||||
|
||||
public function user(): BelongsTo { return $this->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'); }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\GameComment;
|
||||
use App\Models\User;
|
||||
|
||||
class GameCommentPolicy
|
||||
{
|
||||
public function delete(User $user, GameComment $comment): bool
|
||||
{
|
||||
return $user->id === $comment->user_id;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('game_comments', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Your Library vs ' . ($user->username ?? $user->name) . ' — GameLog')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10"
|
||||
x-data="{ tab: 'both' }">
|
||||
|
||||
{{-- Header --}}
|
||||
<div class="mb-8">
|
||||
<a href="{{ route('users.show', $user->username) }}"
|
||||
class="text-sm text-slate-500 hover:text-slate-300 transition-colors mb-2 inline-block">
|
||||
← {{ $user->username ?? $user->name }}'s profile
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-white">
|
||||
Your Library vs <span class="text-purple-400">{{ $user->username ?? $user->name }}</span>'s Library
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{{-- Stats Row --}}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-[#16161d] border border-white/10 rounded-xl p-4 text-center">
|
||||
<div class="text-2xl font-bold text-white">{{ $stats['both_count'] }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1 uppercase tracking-wide">In Common</div>
|
||||
</div>
|
||||
<div class="bg-[#16161d] border border-white/10 rounded-xl p-4 text-center">
|
||||
<div class="text-2xl font-bold text-white">{{ $stats['they_not_you'] }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1 uppercase tracking-wide">They Have, You Don't</div>
|
||||
</div>
|
||||
<div class="bg-[#16161d] border border-white/10 rounded-xl p-4 text-center">
|
||||
<div class="text-2xl font-bold text-white">{{ $stats['you_not_them'] }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1 uppercase tracking-wide">You Have, They Don't</div>
|
||||
</div>
|
||||
<div class="bg-[#16161d] border border-white/10 rounded-xl p-4 text-center">
|
||||
@if($stats['avg_rating_diff'] !== null)
|
||||
<div class="text-2xl font-bold text-white">{{ $stats['avg_rating_diff'] }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1 uppercase tracking-wide">Avg Rating Diff</div>
|
||||
@else
|
||||
<div class="text-2xl font-bold text-slate-600">—</div>
|
||||
<div class="text-xs text-slate-500 mt-1 uppercase tracking-wide">Avg Rating Diff</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Tabs --}}
|
||||
<div class="flex gap-1 mb-6 bg-[#16161d] border border-white/10 rounded-xl p-1 w-fit">
|
||||
<button @click="tab = 'both'"
|
||||
:class="tab === 'both' ? 'bg-purple-600 text-white' : 'text-slate-400 hover:text-white'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors">
|
||||
In Common ({{ $stats['both_count'] }})
|
||||
</button>
|
||||
<button @click="tab = 'they'"
|
||||
:class="tab === 'they' ? 'bg-purple-600 text-white' : 'text-slate-400 hover:text-white'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors">
|
||||
They Played, You Haven't ({{ $stats['they_not_you'] }})
|
||||
</button>
|
||||
<button @click="tab = 'you'"
|
||||
:class="tab === 'you' ? 'bg-purple-600 text-white' : 'text-slate-400 hover:text-white'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors">
|
||||
You Played, They Haven't ({{ $stats['you_not_them'] }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Tab: In Common --}}
|
||||
<div x-show="tab === 'both'" x-transition>
|
||||
@if($both->isEmpty())
|
||||
<div class="text-center py-16 text-slate-500">No games in common yet.</div>
|
||||
@else
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
@foreach($both as $item)
|
||||
@php $game = $item['game']; @endphp
|
||||
<div class="bg-[#16161d] border border-white/5 rounded-xl overflow-hidden hover:border-white/20 transition-colors">
|
||||
<a href="{{ route('games.show', $game) }}">
|
||||
@if($game->cover_url)
|
||||
<img src="{{ $game->cover_url }}" alt="{{ $game->title }}" class="w-full aspect-[3/4] object-cover">
|
||||
@else
|
||||
<div class="w-full aspect-[3/4] bg-[#0f0f13] flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-slate-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
@endif
|
||||
</a>
|
||||
<div class="p-3">
|
||||
<a href="{{ route('games.show', $game) }}"
|
||||
class="text-sm font-medium text-slate-200 hover:text-white line-clamp-2 block mb-2 transition-colors">
|
||||
{{ $game->title }}
|
||||
</a>
|
||||
<div class="flex items-center justify-between text-xs text-slate-500">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-slate-600">You</span>
|
||||
@if($item['myEntry']->rating)
|
||||
<span class="text-yellow-400 font-bold">{{ $item['myEntry']->rating }}</span>
|
||||
@else
|
||||
<span class="text-slate-600">—</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="w-px h-3 bg-white/10"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-slate-600">Them</span>
|
||||
@if($item['theirEntry']->rating)
|
||||
<span class="text-purple-400 font-bold">{{ $item['theirEntry']->rating }}</span>
|
||||
@else
|
||||
<span class="text-slate-600">—</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Tab: They Played, You Haven't --}}
|
||||
<div x-show="tab === 'they'" x-transition>
|
||||
@if($theyHaveNotYou->isEmpty())
|
||||
<div class="text-center py-16 text-slate-500">No games here.</div>
|
||||
@else
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
@foreach($theyHaveNotYou as $entry)
|
||||
<div class="bg-[#16161d] border border-white/5 rounded-xl overflow-hidden hover:border-white/20 transition-colors">
|
||||
<a href="{{ route('games.show', $entry->game) }}">
|
||||
@if($entry->game->cover_url)
|
||||
<img src="{{ $entry->game->cover_url }}" alt="{{ $entry->game->title }}" class="w-full aspect-[3/4] object-cover">
|
||||
@else
|
||||
<div class="w-full aspect-[3/4] bg-[#0f0f13] flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-slate-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
@endif
|
||||
</a>
|
||||
<div class="p-3">
|
||||
<a href="{{ route('games.show', $entry->game) }}"
|
||||
class="text-sm font-medium text-slate-200 hover:text-white line-clamp-2 block mb-2 transition-colors">
|
||||
{{ $entry->game->title }}
|
||||
</a>
|
||||
@if($entry->rating)
|
||||
<div class="flex items-center gap-1 text-xs text-slate-500 mb-2">
|
||||
<svg class="w-3 h-3 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
<span class="font-semibold text-white">{{ $entry->rating }}</span><span>/10</span>
|
||||
</div>
|
||||
@endif
|
||||
<form method="POST" action="{{ route('library.store') }}">
|
||||
@csrf
|
||||
<input type="hidden" name="game_id" value="{{ $entry->game->id }}">
|
||||
<input type="hidden" name="status" value="want_to_play">
|
||||
<button type="submit"
|
||||
class="w-full text-xs px-2 py-1.5 bg-purple-600/20 hover:bg-purple-600/40 text-purple-300 hover:text-white border border-purple-600/30 rounded-lg transition-colors">
|
||||
+ Add to Library
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Tab: You Played, They Haven't --}}
|
||||
<div x-show="tab === 'you'" x-transition>
|
||||
@if($youHaveNotThem->isEmpty())
|
||||
<div class="text-center py-16 text-slate-500">No games here.</div>
|
||||
@else
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
@foreach($youHaveNotThem as $entry)
|
||||
<div class="bg-[#16161d] border border-white/5 rounded-xl overflow-hidden hover:border-white/20 transition-colors">
|
||||
<a href="{{ route('games.show', $entry->game) }}">
|
||||
@if($entry->game->cover_url)
|
||||
<img src="{{ $entry->game->cover_url }}" alt="{{ $entry->game->title }}" class="w-full aspect-[3/4] object-cover">
|
||||
@else
|
||||
<div class="w-full aspect-[3/4] bg-[#0f0f13] flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-slate-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
@endif
|
||||
</a>
|
||||
<div class="p-3">
|
||||
<a href="{{ route('games.show', $entry->game) }}"
|
||||
class="text-sm font-medium text-slate-200 hover:text-white line-clamp-2 block mb-2 transition-colors">
|
||||
{{ $entry->game->title }}
|
||||
</a>
|
||||
@if($entry->rating)
|
||||
<div class="flex items-center gap-1 text-xs text-slate-500">
|
||||
<svg class="w-3 h-3 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
<span class="font-semibold text-white">{{ $entry->rating }}</span><span>/10</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
@@ -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,
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
@auth
|
||||
@if(auth()->id() !== $user->id)
|
||||
<div x-data="{
|
||||
@@ -92,6 +92,14 @@
|
||||
class="px-4 py-2 border border-white/10 hover:border-white/20 text-slate-300 hover:text-white text-sm font-medium rounded-lg transition-colors">
|
||||
View Library
|
||||
</a>
|
||||
@auth
|
||||
@if(auth()->id() !== $user->id)
|
||||
<a href="{{ route('users.compare', $user->username) }}"
|
||||
class="px-4 py-2 border border-purple-500/40 hover:border-purple-500 text-purple-300 hover:text-white text-sm font-medium rounded-lg transition-colors">
|
||||
Compare Libraries
|
||||
</a>
|
||||
@endif
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\CompareController;
|
||||
use App\Http\Controllers\GameCommentController;
|
||||
use App\Http\Controllers\GameController;
|
||||
use App\Http\Controllers\HomeController;
|
||||
use App\Http\Controllers\LibraryController;
|
||||
@@ -21,6 +23,11 @@ Route::get('/games/{game:slug}', [GameController::class, 'show'])->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');
|
||||
|
||||
Reference in New Issue
Block a user