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:
Mark
2026-05-30 02:50:34 +03:00
co-authored by Claude Sonnet 4.6
parent 93d869cf8c
commit 5c6ba86e15
14 changed files with 795 additions and 39 deletions
+97
View File
@@ -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
+88
View File
@@ -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