Add reviews, search autocomplete, SEO meta/schema, user lists
- Review system: ReviewController (store/update/destroy), ReviewPolicy, Review model boot() hooks, review form with Alpine.js (spoiler toggle, edit/delete UX) - Search autocomplete: Alpine.js searchWidget in nav with 300ms debounce, keyboard navigation, 6-result dropdown - SEO: og:image, twitter:card in layout; og sections + Schema.org VideoGame JSON-LD on game show pages - Custom lists: UserList/UserListGame models, ListController, ListPolicy, lists index/show views, Add-to-List button on game pages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
93d869cf8c
commit
5c6ba86e15
@@ -44,12 +44,16 @@ class GameController extends Controller
|
|||||||
{
|
{
|
||||||
$game->load(['genres', 'platforms', 'companies', 'screenshots', 'reviews.user']);
|
$game->load(['genres', 'platforms', 'companies', 'screenshots', 'reviews.user']);
|
||||||
|
|
||||||
$userGame = null;
|
$userGame = null;
|
||||||
|
$userReview = null;
|
||||||
|
$userLists = collect();
|
||||||
if (auth()->check()) {
|
if (auth()->check()) {
|
||||||
$userGame = $game->userGames()->where('user_id', auth()->id())->first();
|
$userGame = $game->userGames()->where('user_id', auth()->id())->first();
|
||||||
|
$userReview = $game->reviews()->where('user_id', auth()->id())->first();
|
||||||
|
$userLists = auth()->user()->lists()->get(['id', 'title']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('games.show', compact('game', 'userGame'));
|
return view('games.show', compact('game', 'userGame', 'userReview', 'userLists'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function search(Request $request)
|
public function search(Request $request)
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Game;
|
||||||
|
use App\Models\UserList;
|
||||||
|
use App\Models\UserListGame;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ListController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->middleware('auth')->except('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$lists = auth()->user()->lists()->withCount('listGames')->latest()->get();
|
||||||
|
return view('lists.index', compact('lists'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'title' => 'required|string|max:100',
|
||||||
|
'description' => 'nullable|string|max:1000',
|
||||||
|
'is_public' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$slug = Str::slug($data['title']);
|
||||||
|
$base = $slug;
|
||||||
|
$i = 1;
|
||||||
|
while (auth()->user()->lists()->where('slug', $slug)->exists()) {
|
||||||
|
$slug = $base . '-' . $i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = UserList::create([
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'title' => $data['title'],
|
||||||
|
'slug' => $slug,
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'is_public' => $data['is_public'] ?? true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('lists.show', $list)->with('success', 'List created!');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(UserList $list)
|
||||||
|
{
|
||||||
|
if (!$list->is_public && (!auth()->check() || auth()->id() !== $list->user_id)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$list->load(['listGames.game', 'user']);
|
||||||
|
|
||||||
|
return view('lists.show', compact('list'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addGame(Request $request, UserList $list)
|
||||||
|
{
|
||||||
|
$this->authorize('manage', $list);
|
||||||
|
|
||||||
|
$data = $request->validate(['game_id' => 'required|exists:games,id']);
|
||||||
|
|
||||||
|
UserListGame::firstOrCreate([
|
||||||
|
'user_list_id' => $list->id,
|
||||||
|
'game_id' => $data['game_id'],
|
||||||
|
], ['order' => $list->listGames()->count()]);
|
||||||
|
|
||||||
|
return response()->json(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeGame(UserList $list, Game $game)
|
||||||
|
{
|
||||||
|
$this->authorize('manage', $list);
|
||||||
|
|
||||||
|
$list->listGames()->where('game_id', $game->id)->delete();
|
||||||
|
|
||||||
|
return response()->json(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(UserList $list)
|
||||||
|
{
|
||||||
|
$this->authorize('manage', $list);
|
||||||
|
$list->delete();
|
||||||
|
|
||||||
|
return redirect()->route('lists.index')->with('success', 'List deleted.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Game;
|
||||||
|
use App\Models\Review;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ReviewController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request, Game $game)
|
||||||
|
{
|
||||||
|
$this->authorize('create', [Review::class, $game]);
|
||||||
|
|
||||||
|
if ($game->reviews()->where('user_id', auth()->id())->exists()) {
|
||||||
|
return response()->json(['message' => 'You have already reviewed this game.'], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'body' => 'required|string|min:50',
|
||||||
|
'rating' => 'nullable|integer|min:1|max:10',
|
||||||
|
'has_spoilers' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$review = Review::create([
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'game_id' => $game->id,
|
||||||
|
'body' => $data['body'],
|
||||||
|
'rating' => $data['rating'] ?? null,
|
||||||
|
'has_spoilers' => $data['has_spoilers'] ?? false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$review->load('user');
|
||||||
|
|
||||||
|
return response()->json(['success' => true, 'review' => $review]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Review $review)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $review);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'body' => 'required|string|min:50',
|
||||||
|
'rating' => 'nullable|integer|min:1|max:10',
|
||||||
|
'has_spoilers' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$review->update($data);
|
||||||
|
|
||||||
|
return response()->json(['success' => true, 'review' => $review]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Review $review)
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $review);
|
||||||
|
$review->delete();
|
||||||
|
|
||||||
|
return response()->json(['success' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-2
@@ -1,13 +1,29 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class Review extends Model
|
class Review extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = ['user_id','game_id','rating','body','has_spoilers','helpful_count'];
|
protected $fillable = ['user_id', 'game_id', 'body', 'rating', 'has_spoilers'];
|
||||||
protected $casts = ['has_spoilers'=>'boolean'];
|
|
||||||
|
protected $casts = ['has_spoilers' => 'boolean'];
|
||||||
|
|
||||||
|
protected static function boot(): void
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::saved(function (Review $review) {
|
||||||
|
$review->game->recalculateRating();
|
||||||
|
});
|
||||||
|
|
||||||
|
static::deleted(function (Review $review) {
|
||||||
|
$review->game->recalculateRating();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function user(): BelongsTo { return $this->belongsTo(User::class); }
|
public function user(): BelongsTo { return $this->belongsTo(User::class); }
|
||||||
public function game(): BelongsTo { return $this->belongsTo(Game::class); }
|
public function game(): BelongsTo { return $this->belongsTo(Game::class); }
|
||||||
|
|||||||
+24
-1
@@ -3,8 +3,31 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class UserList extends Model
|
class UserList extends Model
|
||||||
{
|
{
|
||||||
//
|
protected $fillable = ['user_id', 'title', 'slug', 'description', 'is_public'];
|
||||||
|
|
||||||
|
protected $casts = ['is_public' => 'boolean'];
|
||||||
|
|
||||||
|
protected static function boot(): void
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::creating(function (UserList $list) {
|
||||||
|
if (empty($list->slug)) {
|
||||||
|
$list->slug = Str::slug($list->title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo { return $this->belongsTo(User::class); }
|
||||||
|
|
||||||
|
public function listGames(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserListGame::class, 'user_list_id')->orderBy('order');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
class UserListGame extends Model
|
class UserListGame extends Model
|
||||||
{
|
{
|
||||||
//
|
protected $fillable = ['user_list_id', 'game_id', 'order', 'notes'];
|
||||||
|
|
||||||
|
public function list(): BelongsTo { return $this->belongsTo(UserList::class, 'user_list_id'); }
|
||||||
|
public function game(): BelongsTo { return $this->belongsTo(Game::class); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserList;
|
||||||
|
|
||||||
|
class ListPolicy
|
||||||
|
{
|
||||||
|
public function manage(User $user, UserList $list): bool
|
||||||
|
{
|
||||||
|
return $user->id === $list->user_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Game;
|
||||||
|
use App\Models\Review;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class ReviewPolicy
|
||||||
|
{
|
||||||
|
public function create(User $user, Game $game): bool
|
||||||
|
{
|
||||||
|
return $user->library()->where('game_id', $game->id)->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, Review $review): bool
|
||||||
|
{
|
||||||
|
return $user->id === $review->user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Review $review): bool
|
||||||
|
{
|
||||||
|
return $user->id === $review->user_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,5 +23,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
Gate::policy(UserGame::class, UserGamePolicy::class);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
|
|
||||||
@section('title', $game->title . ' — GameLog')
|
@section('title', $game->title . ' — GameLog')
|
||||||
@section('description', $game->summary ? Str::limit($game->summary, 155) : 'Track ' . $game->title . ' on GameLog.')
|
@section('description', $game->summary ? Str::limit($game->summary, 155) : 'Track ' . $game->title . ' on GameLog.')
|
||||||
|
@section('og_title', $game->title . ' — GameLog')
|
||||||
|
@section('og_description', $game->summary ? Str::limit($game->summary, 155) : 'Track ' . $game->title . ' on GameLog.')
|
||||||
|
@section('og_image', $game->cover_url ?? asset('images/og-default.jpg'))
|
||||||
|
@section('og_type', 'game')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
||||||
@@ -77,7 +81,7 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- Add to Library --}}
|
{{-- Add to Library --}}
|
||||||
<div class="mb-6" x-data="libraryWidget({{ $game->id }}, '{{ $userGame?->status }}', {{ $userGame?->rating ?? 'null' }})">
|
<div class="mb-3" x-data="libraryWidget({{ $game->id }}, '{{ $userGame?->status }}', {{ $userGame?->rating ?? 'null' }})">
|
||||||
|
|
||||||
@if($userGame)
|
@if($userGame)
|
||||||
{{-- Already in library --}}
|
{{-- Already in library --}}
|
||||||
@@ -179,6 +183,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Add to List --}}
|
||||||
|
@auth
|
||||||
|
<div class="mb-6" x-data="addToListWidget({{ $game->id }}, {{ $userLists->toJson() }})">
|
||||||
|
<button @click="open = !open"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-[#16161d] border border-white/10 hover:border-purple-500/50 text-slate-300 hover:text-white text-sm rounded-lg transition-all">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
Add to List
|
||||||
|
</button>
|
||||||
|
<div x-show="open" @click.outside="open = false" x-transition
|
||||||
|
class="mt-1 w-56 bg-[#16161d] border border-white/10 rounded-lg shadow-xl overflow-hidden z-10"
|
||||||
|
style="display:none;">
|
||||||
|
@if($userLists->isEmpty())
|
||||||
|
<div class="px-4 py-3 text-sm text-slate-500">
|
||||||
|
No lists yet.
|
||||||
|
<a href="{{ route('lists.index') }}" class="text-purple-400 hover:text-purple-300">Create one</a>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<template x-for="list in lists" :key="list.id">
|
||||||
|
<button type="button" @click="toggleList(list.id)"
|
||||||
|
class="w-full flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-white/5 transition-colors"
|
||||||
|
:class="added[list.id] ? 'text-purple-400' : 'text-slate-300'">
|
||||||
|
<svg x-show="added[list.id]" class="w-4 h-4 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
<span x-show="!added[list.id]" class="w-4 h-4 flex-shrink-0"></span>
|
||||||
|
<span x-text="list.title" class="truncate"></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<div class="border-t border-white/5 px-4 py-2">
|
||||||
|
<a href="{{ route('lists.index') }}" class="text-xs text-slate-500 hover:text-slate-300 transition-colors">Manage lists →</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="mb-6"></div>
|
||||||
|
@endauth
|
||||||
|
|
||||||
{{-- Companies --}}
|
{{-- Companies --}}
|
||||||
@if($game->companies->isNotEmpty())
|
@if($game->companies->isNotEmpty())
|
||||||
<div class="text-sm text-slate-500 space-y-1">
|
<div class="text-sm text-slate-500 space-y-1">
|
||||||
@@ -272,43 +316,137 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- Reviews --}}
|
{{-- Reviews --}}
|
||||||
<div class="mt-10 pt-8 border-t border-white/5">
|
<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>
|
<h2 class="text-lg font-semibold text-white mb-4">Reviews</h2>
|
||||||
@if($game->reviews->isEmpty())
|
<div class="max-w-3xl">
|
||||||
<p class="text-slate-500">No reviews yet. Be the first!</p>
|
|
||||||
@else
|
{{-- Review List --}}
|
||||||
<div class="space-y-4 max-w-3xl">
|
<div id="review-list" class="space-y-4 mb-8">
|
||||||
@foreach($game->reviews->take(5) as $review)
|
@forelse($game->reviews->take(10) as $review)
|
||||||
<div class="bg-[#16161d] rounded-lg p-5 border border-white/5">
|
<div class="bg-[#16161d] rounded-lg p-5 border border-white/5" id="review-{{ $review->id }}">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<div class="flex items-center gap-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">
|
<div class="w-7 h-7 rounded-full bg-purple-700 flex items-center justify-center text-xs font-bold text-white">
|
||||||
{{ strtoupper(substr($review->user->name, 0, 1)) }}
|
{{ strtoupper(substr($review->user->name, 0, 1)) }}
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm font-medium text-slate-300">{{ $review->user->username ?? $review->user->name }}</span>
|
<span class="text-sm font-medium text-slate-300">{{ $review->user->username ?? $review->user->name }}</span>
|
||||||
|
@if($review->has_spoilers)
|
||||||
|
<span class="px-1.5 py-0.5 text-xs bg-yellow-900/40 text-yellow-400 border border-yellow-700/30 rounded">Spoilers</span>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@if($review->rating)
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex items-center gap-1">
|
@if($review->rating)
|
||||||
<svg class="w-3.5 h-3.5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
<div class="flex items-center gap-1">
|
||||||
<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 class="w-3.5 h-3.5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
</svg>
|
<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"/>
|
||||||
<span class="text-sm font-bold text-white">{{ $review->rating }}</span>
|
</svg>
|
||||||
<span class="text-slate-500 text-xs">/10</span>
|
<span class="text-sm font-bold text-white">{{ $review->rating }}</span>
|
||||||
|
<span class="text-slate-500 text-xs">/10</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@auth
|
||||||
|
@if(auth()->id() === $review->user_id)
|
||||||
|
<button onclick="deleteReview({{ $review->id }})"
|
||||||
|
class="text-xs text-red-500 hover:text-red-400 transition-colors">Delete</button>
|
||||||
|
@endif
|
||||||
|
@endauth
|
||||||
</div>
|
</div>
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
@if($review->body)
|
|
||||||
<p class="text-slate-400 text-sm leading-relaxed">{{ $review->body }}</p>
|
<p class="text-slate-400 text-sm leading-relaxed">{{ $review->body }}</p>
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@empty
|
||||||
|
<p class="text-slate-500" id="no-reviews-msg">No reviews yet. Be the first!</p>
|
||||||
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
@endif
|
|
||||||
|
{{-- Write / Edit Review Form --}}
|
||||||
|
@auth
|
||||||
|
<div x-data="reviewForm(
|
||||||
|
'{{ route('reviews.store', $game->slug) }}',
|
||||||
|
{{ $userReview ? $userReview->id : 'null' }},
|
||||||
|
{{ $userReview ? json_encode($userReview->body) : 'null' }},
|
||||||
|
{{ $userReview ? ($userReview->rating ?? 'null') : 'null' }},
|
||||||
|
{{ $userReview ? ($userReview->has_spoilers ? 'true' : 'false') : 'false' }}
|
||||||
|
)"
|
||||||
|
class="bg-[#16161d] border border-white/10 rounded-lg p-5">
|
||||||
|
|
||||||
|
<h3 class="text-sm font-semibold text-white mb-4" x-text="reviewId ? 'Your Review' : 'Write a Review'"></h3>
|
||||||
|
|
||||||
|
<div x-show="!reviewId || editing">
|
||||||
|
<textarea x-model="body" rows="5" placeholder="Share your thoughts (minimum 50 characters)…"
|
||||||
|
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"></textarea>
|
||||||
|
<p class="text-xs text-slate-500 mt-1 mb-3" x-text="body.length + ' / 50 min'"></p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-4 mb-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm text-slate-400">Rating (optional):</label>
|
||||||
|
<input type="number" min="1" max="10" x-model="rating"
|
||||||
|
class="w-16 bg-[#0f0f13] border border-white/10 rounded-lg px-2 py-1.5 text-sm text-center text-slate-200 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500"
|
||||||
|
placeholder="—">
|
||||||
|
<span class="text-slate-500 text-sm">/10</span>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" x-model="hasSpoilers" class="w-4 h-4 accent-purple-600">
|
||||||
|
<span class="text-sm text-slate-400">Contains spoilers</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button @click="submitReview()"
|
||||||
|
:disabled="body.length < 50"
|
||||||
|
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"
|
||||||
|
x-text="reviewId ? 'Update Review' : 'Submit Review'"></button>
|
||||||
|
<button x-show="reviewId" @click="editing = false"
|
||||||
|
class="px-4 py-2 text-slate-400 hover:text-slate-200 text-sm transition-colors">Cancel</button>
|
||||||
|
<span x-show="error" class="text-sm text-red-400" x-text="error"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="reviewId && !editing" class="flex items-center gap-3">
|
||||||
|
<p class="text-sm text-slate-400">You've already reviewed this game.</p>
|
||||||
|
<button @click="editing = true" class="text-sm text-purple-400 hover:text-purple-300 transition-colors">Edit</button>
|
||||||
|
<button @click="deleteMyReview()" class="text-sm text-red-400 hover:text-red-300 transition-colors">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="bg-[#16161d] border border-white/10 rounded-lg p-5 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 write a review.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@endauth
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@push('scripts')
|
@push('scripts')
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "VideoGame",
|
||||||
|
"name": {{ json_encode($game->title) }},
|
||||||
|
"description": {{ json_encode($game->summary ?? '') }},
|
||||||
|
"url": {{ json_encode(route('games.show', $game)) }}
|
||||||
|
@if($game->genres->isNotEmpty())
|
||||||
|
,"genre": {{ json_encode($game->genres->pluck('name')->values()) }}
|
||||||
|
@endif
|
||||||
|
@if($game->platforms->isNotEmpty())
|
||||||
|
,"gamePlatform": {{ json_encode($game->platforms->pluck('name')->values()) }}
|
||||||
|
@endif
|
||||||
|
@if($game->rating_count > 0)
|
||||||
|
,"aggregateRating": {
|
||||||
|
"@type": "AggregateRating",
|
||||||
|
"ratingValue": {{ $game->rating_avg }},
|
||||||
|
"ratingCount": {{ $game->rating_count }},
|
||||||
|
"bestRating": 10,
|
||||||
|
"worstRating": 1
|
||||||
|
}
|
||||||
|
@endif
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<script>
|
<script>
|
||||||
function libraryWidget(gameId, initialStatus, initialRating) {
|
function libraryWidget(gameId, initialStatus, initialRating) {
|
||||||
return {
|
return {
|
||||||
@@ -388,6 +526,105 @@ function libraryWidget(gameId, initialStatus, initialRating) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reviewForm(storeUrl, initialReviewId, initialBody, initialRating, initialSpoilers) {
|
||||||
|
return {
|
||||||
|
storeUrl,
|
||||||
|
reviewId: initialReviewId,
|
||||||
|
body: initialBody || '',
|
||||||
|
rating: initialRating,
|
||||||
|
hasSpoilers: initialSpoilers || false,
|
||||||
|
editing: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
async submitReview() {
|
||||||
|
this.error = null;
|
||||||
|
const isEdit = !!this.reviewId;
|
||||||
|
const url = isEdit ? `/reviews/${this.reviewId}` : this.storeUrl;
|
||||||
|
const method = isEdit ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
body: this.body,
|
||||||
|
rating: this.rating || null,
|
||||||
|
has_spoilers: this.hasSpoilers,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 409) {
|
||||||
|
this.error = 'You already have a review for this game.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
this.error = data.message || 'Something went wrong.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteMyReview() {
|
||||||
|
if (!confirm('Delete your review?')) return;
|
||||||
|
const res = await fetch(`/reviews/${this.reviewId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.ok) window.location.reload();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteReview(id) {
|
||||||
|
if (!confirm('Delete this review?')) return;
|
||||||
|
fetch(`/reviews/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
}).then(r => { if (r.ok) window.location.reload(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToListWidget(gameId, lists) {
|
||||||
|
return {
|
||||||
|
gameId,
|
||||||
|
lists,
|
||||||
|
open: false,
|
||||||
|
added: {},
|
||||||
|
|
||||||
|
async toggleList(listId) {
|
||||||
|
const isAdded = this.added[listId];
|
||||||
|
const url = isAdded
|
||||||
|
? `/lists/${listId}/games/${this.gameId}`
|
||||||
|
: `/lists/${listId}/games`;
|
||||||
|
const method = isAdded ? 'DELETE' : 'POST';
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: method === 'POST' ? JSON.stringify({ game_id: this.gameId }) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
this.added[listId] = !isAdded;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
@endpush
|
@endpush
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,12 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
<!-- Open Graph -->
|
<!-- Open Graph -->
|
||||||
<meta property="og:title" content="@yield('og_title', 'GameLog')">
|
<meta property="og:title" content="@yield('og_title', 'GameLog — Track, Rate & Discover Video Games')">
|
||||||
<meta property="og:description" content="@yield('og_description', 'Track every game you play.')">
|
<meta property="og:description" content="@yield('og_description', 'Track every game you play. Rate, review, and discover new games.')">
|
||||||
|
<meta property="og:image" content="@yield('og_image', asset('images/og-default.jpg'))">
|
||||||
<meta property="og:type" content="@yield('og_type', 'website')">
|
<meta property="og:type" content="@yield('og_type', 'website')">
|
||||||
<meta property="og:url" content="@yield('og_url', url()->current())">
|
<meta property="og:url" content="@yield('og_url', url()->current())">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
|
||||||
<!-- Fonts -->
|
<!-- Fonts -->
|
||||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||||
@@ -42,15 +44,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center: Search -->
|
<!-- Center: Search with Autocomplete -->
|
||||||
<div class="flex-1 max-w-md mx-8 hidden md:block">
|
<div class="flex-1 max-w-md mx-8 hidden md:block" x-data="searchWidget()">
|
||||||
<form action="{{ route('games.search') }}" method="GET">
|
<form @submit.prevent="goToSearch()" class="relative">
|
||||||
<div class="relative">
|
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none z-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0"/>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0"/>
|
</svg>
|
||||||
</svg>
|
<input type="text" x-model="query" @input.debounce.300ms="fetchResults()"
|
||||||
<input type="text" name="q" value="{{ request('q') }}" placeholder="Search games…"
|
@focus="if (results.length) open = true"
|
||||||
class="w-full bg-[#0f0f13] border border-white/10 rounded-lg pl-10 pr-4 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition">
|
@keydown.escape="open = false; results = []"
|
||||||
|
@keydown.arrow-down.prevent="focusResult(activeIndex + 1)"
|
||||||
|
@keydown.arrow-up.prevent="focusResult(activeIndex - 1)"
|
||||||
|
@keydown.enter.prevent="activeIndex >= 0 && results[activeIndex] ? navigateToResult(results[activeIndex]) : goToSearch()"
|
||||||
|
placeholder="Search games…" autocomplete="off"
|
||||||
|
class="w-full bg-[#0f0f13] border border-white/10 rounded-lg pl-10 pr-4 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition">
|
||||||
|
|
||||||
|
<!-- Autocomplete Dropdown -->
|
||||||
|
<div x-show="open && results.length > 0" @click.outside="open = false"
|
||||||
|
x-transition:enter="transition ease-out duration-100"
|
||||||
|
x-transition:enter-start="opacity-0 -translate-y-1"
|
||||||
|
x-transition:enter-end="opacity-100 translate-y-0"
|
||||||
|
class="absolute top-full left-0 right-0 mt-1 bg-[#16161d] border border-white/10 rounded-lg shadow-xl overflow-hidden z-50"
|
||||||
|
style="display:none;">
|
||||||
|
<template x-for="(result, i) in results" :key="result.id">
|
||||||
|
<button type="button"
|
||||||
|
@click="navigateToResult(result)"
|
||||||
|
@mouseenter="activeIndex = i"
|
||||||
|
:class="activeIndex === i ? 'bg-white/5' : ''"
|
||||||
|
class="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-white/5 transition-colors">
|
||||||
|
<template x-if="result.cover_thumb">
|
||||||
|
<img :src="result.cover_thumb" alt=""
|
||||||
|
class="w-8 h-10 object-cover rounded flex-shrink-0 bg-[#0f0f13]">
|
||||||
|
</template>
|
||||||
|
<template x-if="!result.cover_thumb">
|
||||||
|
<div class="w-8 h-10 flex-shrink-0 bg-[#0f0f13] rounded flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-slate-600" 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>
|
||||||
|
</template>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm text-slate-200 truncate" x-text="result.title"></div>
|
||||||
|
<div class="text-xs text-slate-500" x-text="result.release_year ?? ''"></div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,7 +115,7 @@
|
|||||||
@if(auth()->user()->username)
|
@if(auth()->user()->username)
|
||||||
<a href="{{ route('users.show', auth()->user()->username) }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">Profile</a>
|
<a href="{{ route('users.show', auth()->user()->username) }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">Profile</a>
|
||||||
<a href="{{ route('users.library', auth()->user()->username) }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">My Library</a>
|
<a href="{{ route('users.library', auth()->user()->username) }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">My Library</a>
|
||||||
<a href="{{ route('users.show', auth()->user()->username) }}#lists" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">My Lists</a>
|
<a href="{{ route('lists.index') }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">My Lists</a>
|
||||||
@endif
|
@endif
|
||||||
<a href="{{ route('profile.edit') }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">Settings</a>
|
<a href="{{ route('profile.edit') }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">Settings</a>
|
||||||
<div class="border-t border-white/5"></div>
|
<div class="border-t border-white/5"></div>
|
||||||
@@ -134,5 +172,47 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@stack('scripts')
|
@stack('scripts')
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function searchWidget() {
|
||||||
|
return {
|
||||||
|
query: '',
|
||||||
|
results: [],
|
||||||
|
open: false,
|
||||||
|
activeIndex: -1,
|
||||||
|
|
||||||
|
async fetchResults() {
|
||||||
|
if (this.query.length < 2) {
|
||||||
|
this.results = [];
|
||||||
|
this.open = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch(`/games/search?q=${encodeURIComponent(this.query)}`, {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
this.results = data.slice(0, 6);
|
||||||
|
this.activeIndex = -1;
|
||||||
|
this.open = this.results.length > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
focusResult(i) {
|
||||||
|
const max = this.results.length - 1;
|
||||||
|
this.activeIndex = Math.max(0, Math.min(i, max));
|
||||||
|
},
|
||||||
|
|
||||||
|
navigateToResult(result) {
|
||||||
|
window.location.href = result.url;
|
||||||
|
},
|
||||||
|
|
||||||
|
goToSearch() {
|
||||||
|
if (this.query.trim()) {
|
||||||
|
window.location.href = `/games?q=${encodeURIComponent(this.query.trim())}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
@php use Illuminate\Support\Str; @endphp
|
||||||
|
|
||||||
|
@section('title', 'My Lists — GameLog')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-white">My Lists</h1>
|
||||||
|
<button @click="$dispatch('open-create-list')"
|
||||||
|
class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm font-semibold rounded-lg transition-colors"
|
||||||
|
x-data>
|
||||||
|
+ New List
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($lists->isEmpty())
|
||||||
|
<div class="text-center py-16">
|
||||||
|
<p class="text-slate-500 mb-4">You haven't created any lists yet.</p>
|
||||||
|
<button @click="$dispatch('open-create-list')"
|
||||||
|
class="px-5 py-2.5 bg-purple-600 hover:bg-purple-700 text-white text-sm font-semibold rounded-lg transition-colors"
|
||||||
|
x-data>
|
||||||
|
Create your first list
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
@foreach($lists as $list)
|
||||||
|
<a href="{{ route('lists.show', $list) }}"
|
||||||
|
class="group block bg-[#16161d] border border-white/5 rounded-lg p-5 hover:border-purple-500/50 transition-all">
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<h2 class="font-semibold text-white group-hover:text-purple-300 transition-colors">{{ $list->title }}</h2>
|
||||||
|
@if(!$list->is_public)
|
||||||
|
<span class="text-xs text-slate-500 bg-[#0f0f13] px-2 py-0.5 rounded border border-white/10 ml-2 flex-shrink-0">Private</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@if($list->description)
|
||||||
|
<p class="text-sm text-slate-500 line-clamp-2 mb-3">{{ $list->description }}</p>
|
||||||
|
@endif
|
||||||
|
<p class="text-xs text-slate-600">{{ $list->list_games_count }} {{ Str::plural('game', $list->list_games_count) }}</p>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Create List Modal --}}
|
||||||
|
<div x-data="{ open: false, title: '', description: '', isPublic: true }"
|
||||||
|
@open-create-list.window="open = true; $nextTick(() => $el.querySelector('input[name=title]')?.focus())">
|
||||||
|
<div x-show="open"
|
||||||
|
x-transition:enter="transition ease-out duration-150"
|
||||||
|
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||||
|
x-transition:leave="transition ease-in duration-100"
|
||||||
|
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4"
|
||||||
|
style="display:none;"
|
||||||
|
@click.self="open = false"
|
||||||
|
@keydown.escape.window="open = false">
|
||||||
|
<div class="bg-[#16161d] border border-white/10 rounded-xl p-6 w-full max-w-md shadow-2xl">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-5">Create New List</h2>
|
||||||
|
<form method="POST" action="{{ route('lists.store') }}">
|
||||||
|
@csrf
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-slate-400 mb-1.5">Title</label>
|
||||||
|
<input type="text" name="title" x-model="title" required maxlength="100"
|
||||||
|
class="w-full bg-[#0f0f13] border border-white/10 rounded-lg px-4 py-2.5 text-sm text-slate-200 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-slate-400 mb-1.5">Description <span class="text-slate-600">(optional)</span></label>
|
||||||
|
<textarea name="description" x-model="description" rows="3" maxlength="1000"
|
||||||
|
class="w-full bg-[#0f0f13] border border-white/10 rounded-lg px-4 py-2.5 text-sm text-slate-200 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition resize-none"></textarea>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="hidden" name="is_public" value="0">
|
||||||
|
<input type="checkbox" name="is_public" value="1" x-model="isPublic" class="w-4 h-4 accent-purple-600" checked>
|
||||||
|
<span class="text-sm text-slate-400">Public list</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 mt-6">
|
||||||
|
<button type="submit"
|
||||||
|
:disabled="title.trim().length === 0"
|
||||||
|
class="flex-1 py-2.5 bg-purple-600 hover:bg-purple-700 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-semibold rounded-lg transition-colors">
|
||||||
|
Create List
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="open = false"
|
||||||
|
class="px-4 py-2.5 text-slate-400 hover:text-slate-200 text-sm transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
@php use Illuminate\Support\Str; @endphp
|
||||||
|
|
||||||
|
@section('title', $list->title . ' — GameLog')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
||||||
|
|
||||||
|
{{-- Header --}}
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<a href="{{ route('users.show', $list->user->username) }}" class="text-sm text-slate-500 hover:text-slate-300 transition-colors">
|
||||||
|
{{ $list->user->username ?? $list->user->name }}
|
||||||
|
</a>
|
||||||
|
@if(!$list->is_public)
|
||||||
|
<span class="text-xs text-slate-500 bg-[#16161d] px-2 py-0.5 rounded border border-white/10">Private</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">{{ $list->title }}</h1>
|
||||||
|
</div>
|
||||||
|
@auth
|
||||||
|
@if(auth()->id() === $list->user_id)
|
||||||
|
<form method="POST" action="{{ route('lists.destroy', $list) }}" onsubmit="return confirm('Delete this list and remove all its games?')">
|
||||||
|
@csrf @method('DELETE')
|
||||||
|
<button type="submit" class="text-sm text-red-400 hover:text-red-300 transition-colors">Delete List</button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
@endauth
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($list->description)
|
||||||
|
<p class="text-slate-400 mb-8 max-w-2xl">{{ $list->description }}</p>
|
||||||
|
@else
|
||||||
|
<div class="mb-8"></div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<p class="text-sm text-slate-500 mb-6">{{ $list->listGames->count() }} {{ Str::plural('game', $list->listGames->count()) }}</p>
|
||||||
|
|
||||||
|
@if($list->listGames->isEmpty())
|
||||||
|
<div class="text-center py-16">
|
||||||
|
<p class="text-slate-500">No games in this list yet.</p>
|
||||||
|
@auth
|
||||||
|
@if(auth()->id() === $list->user_id)
|
||||||
|
<p class="text-sm text-slate-600 mt-2">
|
||||||
|
Browse <a href="{{ route('games.index') }}" class="text-purple-400 hover:text-purple-300 transition-colors">games</a>
|
||||||
|
and use "Add to List" to add them here.
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
@endauth
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
|
@foreach($list->listGames as $entry)
|
||||||
|
<div class="group relative">
|
||||||
|
<x-game-card :game="$entry->game" />
|
||||||
|
@auth
|
||||||
|
@if(auth()->id() === $list->user_id)
|
||||||
|
<button onclick="removeGameFromList({{ $list->id }}, {{ $entry->game->id }}, this)"
|
||||||
|
class="absolute top-2 right-2 w-6 h-6 bg-red-600 hover:bg-red-700 text-white rounded-full text-xs font-bold opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center leading-none">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
@endauth
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
function removeGameFromList(listId, gameId, btn) {
|
||||||
|
if (!confirm('Remove from list?')) return;
|
||||||
|
fetch(`/lists/${listId}/games/${gameId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
}).then(r => {
|
||||||
|
if (r.ok) btn.closest('.group').remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
@endsection
|
||||||
@@ -28,6 +28,21 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::delete('/library/{userGame}', [LibraryController::class, 'destroy'])->name('library.destroy');
|
Route::delete('/library/{userGame}', [LibraryController::class, 'destroy'])->name('library.destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reviews (auth required)
|
||||||
|
Route::middleware('auth')->group(function () {
|
||||||
|
Route::post('/games/{game:slug}/reviews', [\App\Http\Controllers\ReviewController::class, 'store'])->name('reviews.store');
|
||||||
|
Route::put('/reviews/{review}', [\App\Http\Controllers\ReviewController::class, 'update'])->name('reviews.update');
|
||||||
|
Route::delete('/reviews/{review}', [\App\Http\Controllers\ReviewController::class, 'destroy'])->name('reviews.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');
|
||||||
|
Route::get('/lists/{list}', [\App\Http\Controllers\ListController::class, 'show'])->name('lists.show');
|
||||||
|
Route::delete('/lists/{list}', [\App\Http\Controllers\ListController::class, 'destroy'])->name('lists.destroy')->middleware('auth');
|
||||||
|
Route::post('/lists/{list}/games', [\App\Http\Controllers\ListController::class, 'addGame'])->name('lists.add-game')->middleware('auth');
|
||||||
|
Route::delete('/lists/{list}/games/{game}', [\App\Http\Controllers\ListController::class, 'removeGame'])->name('lists.remove-game')->middleware('auth');
|
||||||
|
|
||||||
// Dashboard (Breeze default, keep for now)
|
// Dashboard (Breeze default, keep for now)
|
||||||
Route::get('/dashboard', function () {
|
Route::get('/dashboard', function () {
|
||||||
return redirect()->route('home');
|
return redirect()->route('home');
|
||||||
|
|||||||
Reference in New Issue
Block a user