- 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>
219 lines
13 KiB
PHP
219 lines
13 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark h-full">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
<title>@yield('title', 'GameLog — Track Every Game You Play')</title>
|
|
<meta name="description" content="@yield('description', 'GameLog lets you track every game you play, rate your favorites, and discover what to play next.')">
|
|
@hasSection('canonical')
|
|
<link rel="canonical" href="@yield('canonical')">
|
|
@endif
|
|
|
|
<!-- Open Graph -->
|
|
<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. 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:url" content="@yield('og_url', url()->current())">
|
|
<meta name="twitter:card" content="summary_large_image">
|
|
|
|
<!-- Fonts -->
|
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
|
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet" />
|
|
|
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
|
@stack('styles')
|
|
</head>
|
|
<body class="h-full bg-[#0f0f13] text-slate-200 antialiased font-sans">
|
|
|
|
<!-- Navigation -->
|
|
<nav class="bg-[#16161d] border-b border-white/5 sticky top-0 z-50">
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div class="flex items-center justify-between h-16">
|
|
|
|
<!-- Left: Logo + Nav Links -->
|
|
<div class="flex items-center gap-8">
|
|
<a href="{{ route('home') }}" class="text-xl font-bold text-purple-400 hover:text-purple-300 transition-colors">
|
|
GameLog
|
|
</a>
|
|
<div class="hidden md:flex items-center gap-6">
|
|
<a href="{{ route('games.index') }}" class="text-sm text-slate-400 hover:text-slate-200 transition-colors">Browse</a>
|
|
<a href="{{ route('games.top-rated') }}" class="text-sm text-slate-400 hover:text-slate-200 transition-colors">Top Rated</a>
|
|
<a href="{{ route('games.new-releases') }}" class="text-sm text-slate-400 hover:text-slate-200 transition-colors">New Releases</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Center: Search with Autocomplete -->
|
|
<div class="flex-1 max-w-md mx-8 hidden md:block" x-data="searchWidget()">
|
|
<form @submit.prevent="goToSearch()" 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">
|
|
<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>
|
|
<input type="text" x-model="query" @input.debounce.300ms="fetchResults()"
|
|
@focus="if (results.length) open = true"
|
|
@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>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Right: Auth -->
|
|
<div class="flex items-center gap-3">
|
|
@auth
|
|
<div class="relative" x-data="{ open: false }">
|
|
<button @click="open = !open" class="flex items-center gap-2 text-sm text-slate-400 hover:text-slate-200 transition-colors">
|
|
@if(auth()->user()->avatar)
|
|
<img src="{{ auth()->user()->avatar }}" class="w-8 h-8 rounded-full object-cover border border-white/10" alt="">
|
|
@else
|
|
<div class="w-8 h-8 rounded-full bg-purple-600 flex items-center justify-center text-xs font-bold text-white">
|
|
{{ strtoupper(substr(auth()->user()->name, 0, 1)) }}
|
|
</div>
|
|
@endif
|
|
<span class="hidden sm:block">{{ auth()->user()->username ?? auth()->user()->name }}</span>
|
|
<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="M19 9l-7 7-7-7"/>
|
|
</svg>
|
|
</button>
|
|
<div x-show="open" @click.outside="open = false" x-transition
|
|
class="absolute right-0 mt-2 w-48 bg-[#16161d] border border-white/10 rounded-lg shadow-xl overflow-hidden z-50">
|
|
@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.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('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
|
|
<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>
|
|
<form method="POST" action="{{ route('logout') }}">
|
|
@csrf
|
|
<button type="submit" class="w-full text-left px-4 py-2.5 text-sm text-slate-400 hover:bg-white/5 hover:text-white transition-colors">
|
|
Log Out
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
@else
|
|
<a href="{{ route('login') }}" class="text-sm text-slate-400 hover:text-slate-200 transition-colors">Log in</a>
|
|
<a href="{{ route('register') }}" class="text-sm px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium">
|
|
Sign up
|
|
</a>
|
|
@endauth
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- Flash Messages -->
|
|
@if(session('success'))
|
|
<div class="bg-green-900/50 border-b border-green-700/50 text-green-300 text-sm text-center py-2 px-4">
|
|
{{ session('success') }}
|
|
</div>
|
|
@endif
|
|
@if(session('error'))
|
|
<div class="bg-red-900/50 border-b border-red-700/50 text-red-300 text-sm text-center py-2 px-4">
|
|
{{ session('error') }}
|
|
</div>
|
|
@endif
|
|
|
|
<!-- Page Content -->
|
|
<main>
|
|
@yield('content')
|
|
</main>
|
|
|
|
<!-- Footer -->
|
|
<footer class="border-t border-white/5 mt-16 py-10">
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div class="flex flex-col md:flex-row items-center justify-between gap-4">
|
|
<div class="text-slate-500 text-sm">
|
|
© {{ date('Y') }} GameLog. Track every game you play.
|
|
</div>
|
|
<div class="flex items-center gap-6 text-sm text-slate-500">
|
|
<a href="{{ route('games.index') }}" class="hover:text-slate-300 transition-colors">Browse</a>
|
|
<a href="{{ route('games.top-rated') }}" class="hover:text-slate-300 transition-colors">Top Rated</a>
|
|
<a href="{{ route('games.new-releases') }}" class="hover:text-slate-300 transition-colors">New Releases</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
|
|
@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>
|
|
</html>
|