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
@@ -3,6 +3,10 @@
|
||||
|
||||
@section('title', $game->title . ' — 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')
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
||||
@@ -77,7 +81,7 @@
|
||||
@endif
|
||||
|
||||
{{-- 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)
|
||||
{{-- Already in library --}}
|
||||
@@ -179,6 +183,46 @@
|
||||
</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 --}}
|
||||
@if($game->companies->isNotEmpty())
|
||||
<div class="text-sm text-slate-500 space-y-1">
|
||||
@@ -272,43 +316,137 @@
|
||||
@endif
|
||||
|
||||
{{-- 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>
|
||||
@if($game->reviews->isEmpty())
|
||||
<p class="text-slate-500">No reviews yet. Be the first!</p>
|
||||
@else
|
||||
<div class="space-y-4 max-w-3xl">
|
||||
@foreach($game->reviews->take(5) as $review)
|
||||
<div class="bg-[#16161d] rounded-lg p-5 border border-white/5">
|
||||
<div class="max-w-3xl">
|
||||
|
||||
{{-- Review List --}}
|
||||
<div id="review-list" class="space-y-4 mb-8">
|
||||
@forelse($game->reviews->take(10) as $review)
|
||||
<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 gap-2">
|
||||
<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)) }}
|
||||
</div>
|
||||
<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>
|
||||
@if($review->rating)
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5 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="text-sm font-bold text-white">{{ $review->rating }}</span>
|
||||
<span class="text-slate-500 text-xs">/10</span>
|
||||
<div class="flex items-center gap-3">
|
||||
@if($review->rating)
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5 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="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>
|
||||
@endif
|
||||
</div>
|
||||
@if($review->body)
|
||||
<p class="text-slate-400 text-sm leading-relaxed">{{ $review->body }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
@empty
|
||||
<p class="text-slate-500" id="no-reviews-msg">No reviews yet. Be the first!</p>
|
||||
@endforelse
|
||||
</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>
|
||||
|
||||
@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>
|
||||
function libraryWidget(gameId, initialStatus, initialRating) {
|
||||
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>
|
||||
@endpush
|
||||
|
||||
|
||||
Reference in New Issue
Block a user