Core implementation: controllers, views, routes, models, import command

This commit is contained in:
Mark
2026-05-29 09:37:07 +03:00
parent 8e1fe4a2f5
commit 5813bc779a
24 changed files with 1361 additions and 37 deletions
+1
View File
@@ -25,3 +25,4 @@ _ide_helper.php
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
Thumbs.db Thumbs.db
.claude/
+146
View File
@@ -0,0 +1,146 @@
<?php
namespace App\Console\Commands;
use App\Models\Company;
use App\Models\Game;
use App\Models\GameScreenshot;
use App\Models\Genre;
use App\Models\Platform;
use App\Services\IgdbService;
use Illuminate\Console\Command;
class ImportGamesCommand extends Command
{
protected $signature = 'games:import {--limit=100} {--offset=0} {--popular} {--new}';
protected $description = 'Import games from IGDB into the local database';
public function handle(IgdbService $igdb): int
{
$limit = (int) $this->option('limit');
$offset = (int) $this->option('offset');
$this->info("Fetching {$limit} games from IGDB (offset: {$offset})…");
try {
if ($this->option('new')) {
$results = $igdb->getNewReleases($limit);
} else {
$results = $igdb->getPopularGames($limit, $offset);
}
} catch (\Exception $e) {
$this->error('IGDB request failed: ' . $e->getMessage());
return self::FAILURE;
}
if (empty($results)) {
$this->warn('No games returned from IGDB.');
return self::SUCCESS;
}
$imported = 0;
$bar = $this->output->createProgressBar(count($results));
$bar->start();
foreach ($results as $data) {
try {
$this->upsertGame($data, $igdb);
$imported++;
} catch (\Exception $e) {
$this->newLine();
$this->warn("Skipped game (igdb_id={$data['id']}): " . $e->getMessage());
}
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->info("Done. Imported/updated {$imported} games.");
return self::SUCCESS;
}
private function upsertGame(array $data, IgdbService $igdb): void
{
$igdbId = $data['id'];
$title = $data['name'] ?? 'Unknown';
$releaseDate = isset($data['first_release_date'])
? date('Y-m-d', $data['first_release_date'])
: null;
$releaseYear = $releaseDate ? substr($releaseDate, 0, 4) : null;
$coverUrl = null;
$coverThumbUrl = null;
if (!empty($data['cover']['image_id'])) {
$coverUrl = $igdb::coverUrl($data['cover']['image_id'], 'cover_big');
$coverThumbUrl = $igdb::coverUrl($data['cover']['image_id'], 'cover_small');
}
$game = Game::updateOrCreate(
['igdb_id' => $igdbId],
[
'title' => $title,
'summary' => $data['summary'] ?? null,
'cover_url' => $coverUrl,
'cover_thumb_url'=> $coverThumbUrl,
'release_date' => $releaseDate,
'release_year' => $releaseYear,
'status' => 'published',
]
);
// Genres
if (!empty($data['genres'])) {
$genreIds = [];
foreach ($data['genres'] as $g) {
$genre = Genre::firstOrCreate(
['igdb_id' => $g['id']],
['name' => $g['name'], 'slug' => \Str::slug($g['name'])]
);
$genreIds[] = $genre->id;
}
$game->genres()->sync($genreIds);
}
// Platforms
if (!empty($data['platforms'])) {
$platformIds = [];
foreach ($data['platforms'] as $p) {
$platform = Platform::firstOrCreate(
['igdb_id' => $p['id']],
['name' => $p['name'], 'slug' => \Str::slug($p['name'])]
);
$platformIds[] = $platform->id;
}
$game->platforms()->sync($platformIds);
}
// Companies (involved_companies)
if (!empty($data['involved_companies'])) {
$companyPivot = [];
foreach ($data['involved_companies'] as $ic) {
if (empty($ic['company']['name'])) continue;
$company = Company::firstOrCreate(
['igdb_id' => $ic['company']['id']],
['name' => $ic['company']['name'], 'slug' => \Str::slug($ic['company']['name'])]
);
$role = $ic['developer'] ? 'developer' : ($ic['publisher'] ? 'publisher' : 'other');
$companyPivot[$company->id] = ['role' => $role];
}
$game->companies()->sync($companyPivot);
}
// Screenshots
if (!empty($data['screenshots'])) {
GameScreenshot::where('game_id', $game->id)->delete();
foreach ($data['screenshots'] as $i => $shot) {
GameScreenshot::create([
'game_id' => $game->id,
'url' => $igdb::screenshotUrl($shot['image_id'], 'screenshot_big'),
'thumb_url' => $igdb::screenshotUrl($shot['image_id'], 'screenshot_med'),
'order' => $i,
]);
}
}
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers;
use App\Models\Game;
use App\Models\Genre;
use App\Models\Platform;
use Illuminate\Http\Request;
class GameController extends Controller
{
public function index(Request $request)
{
$query = Game::published()->with(['genres', 'platforms']);
// Filters
if ($request->filled('genre')) {
$query->whereHas('genres', fn($q) => $q->where('genres.id', $request->genre));
}
if ($request->filled('platform')) {
$query->whereHas('platforms', fn($q) => $q->where('platforms.id', $request->platform));
}
if ($request->filled('year')) {
$query->where('release_year', $request->year);
}
// Sort
match ($request->get('sort', 'rating')) {
'rating' => $query->orderByDesc('rating_avg')->orderByDesc('rating_count'),
'release' => $query->orderByDesc('release_date'),
'title' => $query->orderBy('title'),
'popular' => $query->orderByDesc('library_count'),
default => $query->orderByDesc('rating_avg'),
};
$games = $query->paginate(24)->withQueryString();
$genres = Genre::orderBy('name')->get();
$platforms = Platform::orderBy('name')->get();
return view('games.index', compact('games', 'genres', 'platforms'));
}
public function show(Game $game)
{
$game->load(['genres', 'platforms', 'companies', 'screenshots', 'reviews.user']);
$userGame = null;
if (auth()->check()) {
$userGame = $game->userGames()->where('user_id', auth()->id())->first();
}
return view('games.show', compact('game', 'userGame'));
}
public function search(Request $request)
{
$q = $request->get('q', '');
$games = collect();
if (strlen($q) >= 2) {
$games = Game::published()
->where('title', 'like', "%{$q}%")
->orderByDesc('rating_count')
->limit(20)
->get();
}
if ($request->expectsJson()) {
return response()->json($games->map(fn($g) => [
'id' => $g->id,
'title' => $g->title,
'slug' => $g->slug,
'cover_thumb' => $g->cover_thumb_url,
'release_year' => $g->release_year,
'rating_avg' => $g->rating_avg,
'url' => route('games.show', $g),
]));
}
return view('games.search', compact('games', 'q'));
}
public function topRated(Request $request)
{
$games = Game::published()
->where('rating_count', '>=', 1)
->orderByDesc('rating_avg')
->orderByDesc('rating_count')
->paginate(24)
->withQueryString();
return view('games.top-rated', compact('games'));
}
public function newReleases(Request $request)
{
$games = Game::published()
->whereNotNull('release_date')
->where('release_date', '<=', now())
->orderByDesc('release_date')
->paginate(24)
->withQueryString();
return view('games.new-releases', compact('games'));
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers;
use App\Models\Game;
class HomeController extends Controller
{
public function index()
{
$topRated = Game::published()
->where('rating_count', '>=', 1)
->orderByDesc('rating_avg')
->orderByDesc('rating_count')
->limit(12)
->get();
$newReleases = Game::published()
->whereNotNull('release_date')
->where('release_date', '<=', now())
->orderByDesc('release_date')
->limit(8)
->get();
return view('home', compact('topRated', 'newReleases'));
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers;
use App\Models\Game;
use App\Models\UserGame;
use Illuminate\Http\Request;
class LibraryController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function store(Request $request)
{
$request->validate([
'game_id' => 'required|exists:games,id',
'status' => 'required|in:' . implode(',', UserGame::STATUSES),
'rating' => 'nullable|integer|min:1|max:10',
]);
$userGame = UserGame::updateOrCreate(
['user_id' => auth()->id(), 'game_id' => $request->game_id],
['status' => $request->status, 'rating' => $request->rating]
);
// Recalculate rating on the game
$userGame->game->recalculateRating();
if ($request->expectsJson()) {
return response()->json(['status' => 'ok', 'userGame' => $userGame]);
}
return back()->with('success', 'Added to your library.');
}
public function update(Request $request, UserGame $userGame)
{
$this->authorize('update', $userGame);
$request->validate([
'status' => 'sometimes|in:' . implode(',', UserGame::STATUSES),
'rating' => 'nullable|integer|min:1|max:10',
'notes' => 'nullable|string|max:1000',
]);
$userGame->update($request->only(['status', 'rating', 'notes']));
$userGame->game->recalculateRating();
if ($request->expectsJson()) {
return response()->json(['status' => 'ok', 'userGame' => $userGame]);
}
return back()->with('success', 'Library entry updated.');
}
public function destroy(UserGame $userGame)
{
$this->authorize('delete', $userGame);
$game = $userGame->game;
$userGame->delete();
$game->recalculateRating();
if (request()->expectsJson()) {
return response()->json(['status' => 'ok']);
}
return back()->with('success', 'Removed from your library.');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\UserGame;
class UserController extends Controller
{
public function show(User $user)
{
$stats = [
'played' => $user->library()->where('status', 'played')->count(),
'playing' => $user->library()->where('status', 'playing')->count(),
'want_to_play' => $user->library()->where('status', 'want_to_play')->count(),
'total' => $user->library()->count(),
];
$recentlyPlayed = $user->library()
->with('game')
->where('status', 'played')
->latest('updated_at')
->limit(6)
->get();
return view('users.show', compact('user', 'stats', 'recentlyPlayed'));
}
public function library(User $user)
{
$status = request('status');
$query = $user->library()->with('game');
if ($status && in_array($status, UserGame::STATUSES)) {
$query->where('status', $status);
}
$entries = $query->latest('updated_at')->paginate(24)->withQueryString();
return view('users.library', compact('user', 'entries', 'status'));
}
}
+7 -1
View File
@@ -3,8 +3,14 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Company extends Model class Company extends Model
{ {
// protected $fillable = ['igdb_id', 'name', 'slug'];
public function games(): BelongsToMany
{
return $this->belongsToMany(Game::class, 'game_company')->withPivot('role');
}
} }
+7 -1
View File
@@ -3,8 +3,14 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GameScreenshot extends Model class GameScreenshot extends Model
{ {
// protected $fillable = ['game_id', 'url', 'thumb_url', 'order'];
public function game(): BelongsTo
{
return $this->belongsTo(Game::class);
}
} }
+7 -1
View File
@@ -3,8 +3,14 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Genre extends Model class Genre extends Model
{ {
// protected $fillable = ['igdb_id', 'name', 'slug'];
public function games(): BelongsToMany
{
return $this->belongsToMany(Game::class, 'game_genre');
}
} }
+7 -1
View File
@@ -3,8 +3,14 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Platform extends Model class Platform extends Model
{ {
// protected $fillable = ['igdb_id', 'name', 'slug', 'abbreviation'];
public function games(): BelongsToMany
{
return $this->belongsToMany(Game::class, 'game_platform');
}
} }
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\UserGame;
class UserGamePolicy
{
public function update(User $user, UserGame $userGame): bool
{
return $user->id === $userGame->user_id;
}
public function delete(User $user, UserGame $userGame): bool
{
return $user->id === $userGame->user_id;
}
}
+4 -1
View File
@@ -2,6 +2,9 @@
namespace App\Providers; namespace App\Providers;
use App\Models\UserGame;
use App\Policies\UserGamePolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -19,6 +22,6 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
// Gate::policy(UserGame::class, UserGamePolicy::class);
} }
} }
+12 -2
View File
@@ -102,7 +102,12 @@ class IgdbService
{ {
return $this->query('games', " return $this->query('games', "
fields id,name,slug,summary,cover.image_id,first_release_date, fields id,name,slug,summary,cover.image_id,first_release_date,
genres.name,genres.slug,platforms.abbreviation, genres.id,genres.name,genres.slug,
platforms.id,platforms.name,platforms.slug,platforms.abbreviation,
involved_companies.company.id,involved_companies.company.name,
involved_companies.company.slug,involved_companies.developer,
involved_companies.publisher,
screenshots.image_id,
rating,rating_count; rating,rating_count;
where rating != null & rating_count > 100 & cover != null; where rating != null & rating_count > 100 & cover != null;
sort rating_count desc; sort rating_count desc;
@@ -121,7 +126,12 @@ class IgdbService
return $this->query('games', " return $this->query('games', "
fields id,name,slug,summary,cover.image_id,first_release_date, fields id,name,slug,summary,cover.image_id,first_release_date,
genres.name,genres.slug,platforms.abbreviation, genres.id,genres.name,genres.slug,
platforms.id,platforms.name,platforms.slug,platforms.abbreviation,
involved_companies.company.id,involved_companies.company.name,
involved_companies.company.slug,involved_companies.developer,
involved_companies.publisher,
screenshots.image_id,
rating,rating_count; rating,rating_count;
where first_release_date >= {$timestamp} & first_release_date <= {$now} & cover != null; where first_release_date >= {$timestamp} & first_release_date <= {$now} & cover != null;
sort first_release_date desc; sort first_release_date desc;
@@ -0,0 +1,38 @@
@props(['game'])
<a href="{{ route('games.show', $game) }}"
class="group block bg-[#16161d] rounded-lg overflow-hidden hover:ring-1 hover:ring-purple-500 transition-all duration-200">
<!-- Cover -->
<div class="aspect-[3/4] bg-[#0f0f13] overflow-hidden">
@if($game->cover_thumb_url)
<img src="{{ $game->cover_thumb_url }}"
alt="{{ $game->title }}"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300">
@else
<div class="w-full h-full flex items-center justify-center">
<svg class="w-12 h-12 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>
@endif
</div>
<!-- Info -->
<div class="p-3">
<h3 class="text-sm font-medium text-slate-200 group-hover:text-white truncate leading-tight">
{{ $game->title }}
</h3>
<div class="flex items-center justify-between mt-1.5">
<span class="text-xs text-slate-500">{{ $game->release_year ?? '—' }}</span>
@if($game->rating_avg)
<div class="flex items-center gap-1">
<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="text-xs text-slate-400 font-medium">{{ number_format($game->rating_avg, 1) }}</span>
</div>
@endif
</div>
</div>
</a>
+114
View File
@@ -0,0 +1,114 @@
@extends('layouts.app')
@section('title', 'Browse Games — GameLog')
@section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
<div class="flex flex-col lg:flex-row gap-8">
{{-- Sidebar Filters --}}
<aside class="lg:w-56 flex-shrink-0">
<form method="GET" action="{{ route('games.index') }}" id="filter-form">
<div class="bg-[#16161d] rounded-xl border border-white/5 p-5 space-y-6">
<div>
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Sort By</h3>
<select name="sort" onchange="document.getElementById('filter-form').submit()"
class="w-full bg-[#0f0f13] border border-white/10 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-purple-500">
<option value="rating" {{ request('sort', 'rating') === 'rating' ? 'selected' : '' }}>Top Rated</option>
<option value="release" {{ request('sort') === 'release' ? 'selected' : '' }}>Newest</option>
<option value="popular" {{ request('sort') === 'popular' ? 'selected' : '' }}>Most Popular</option>
<option value="title" {{ request('sort') === 'title' ? 'selected' : '' }}>A–Z</option>
</select>
</div>
@if($genres->isNotEmpty())
<div>
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Genre</h3>
<div class="space-y-1.5 max-h-52 overflow-y-auto pr-1">
<label class="flex items-center gap-2 cursor-pointer group">
<input type="radio" name="genre" value="" {{ !request('genre') ? 'checked' : '' }}
onchange="this.form.submit()"
class="accent-purple-500">
<span class="text-sm text-slate-400 group-hover:text-slate-200 transition-colors">All Genres</span>
</label>
@foreach($genres as $genre)
<label class="flex items-center gap-2 cursor-pointer group">
<input type="radio" name="genre" value="{{ $genre->id }}" {{ request('genre') == $genre->id ? 'checked' : '' }}
onchange="this.form.submit()"
class="accent-purple-500">
<span class="text-sm text-slate-400 group-hover:text-slate-200 transition-colors">{{ $genre->name }}</span>
</label>
@endforeach
</div>
</div>
@endif
@if($platforms->isNotEmpty())
<div>
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Platform</h3>
<div class="space-y-1.5 max-h-52 overflow-y-auto pr-1">
<label class="flex items-center gap-2 cursor-pointer group">
<input type="radio" name="platform" value="" {{ !request('platform') ? 'checked' : '' }}
onchange="this.form.submit()"
class="accent-purple-500">
<span class="text-sm text-slate-400 group-hover:text-slate-200 transition-colors">All Platforms</span>
</label>
@foreach($platforms as $platform)
<label class="flex items-center gap-2 cursor-pointer group">
<input type="radio" name="platform" value="{{ $platform->id }}" {{ request('platform') == $platform->id ? 'checked' : '' }}
onchange="this.form.submit()"
class="accent-purple-500">
<span class="text-sm text-slate-400 group-hover:text-slate-200 transition-colors">{{ $platform->name }}</span>
</label>
@endforeach
</div>
</div>
@endif
@if(request()->anyFilled(['genre', 'platform', 'year']))
<a href="{{ route('games.index') }}"
class="block text-center text-xs text-purple-400 hover:text-purple-300 transition-colors pt-2 border-t border-white/5">
Clear Filters
</a>
@endif
</div>
{{-- Keep sort in URL when filtering --}}
@if(request('sort'))
<input type="hidden" name="sort" value="{{ request('sort') }}">
@endif
</form>
</aside>
{{-- Games Grid --}}
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-bold text-white">
Browse Games
<span class="text-slate-500 font-normal text-base ml-2">({{ number_format($games->total()) }})</span>
</h1>
</div>
@if($games->isEmpty())
<div class="text-center py-24 text-slate-500">
<p class="text-lg">No games found.</p>
<a href="{{ route('games.index') }}" class="mt-3 inline-block text-sm text-purple-400 hover:text-purple-300">Clear filters</a>
</div>
@else
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
@foreach($games as $game)
<x-game-card :game="$game" />
@endforeach
</div>
<div class="mt-8">
{{ $games->links() }}
</div>
@endif
</div>
</div>
</div>
@endsection
@@ -0,0 +1,23 @@
@extends('layouts.app')
@section('title', 'New Releases — GameLog')
@section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
<h1 class="text-2xl font-bold text-white mb-2">New Releases</h1>
<p class="text-slate-500 mb-8">The latest games in the GameLog database.</p>
@if($games->isEmpty())
<div class="text-center py-24 text-slate-500">
<p>No new releases found.</p>
</div>
@else
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
@foreach($games as $game)
<x-game-card :game="$game" />
@endforeach
</div>
<div class="mt-8">{{ $games->links() }}</div>
@endif
</div>
@endsection
+36
View File
@@ -0,0 +1,36 @@
@extends('layouts.app')
@section('title', $q ? "Search: {$q} — GameLog" : 'Search Games — GameLog')
@section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
<h1 class="text-2xl font-bold text-white mb-6">
@if($q)
Search results for "<span class="text-purple-400">{{ $q }}</span>"
@else
Search Games
@endif
</h1>
<form action="{{ route('games.search') }}" method="GET" class="mb-8 max-w-xl">
<div class="relative">
<svg class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 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"/>
</svg>
<input type="text" name="q" value="{{ $q }}" placeholder="Search for a game…" autofocus
class="w-full bg-[#16161d] border border-white/10 rounded-xl pl-12 pr-4 py-3 text-base text-slate-200 placeholder-slate-500 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition">
</div>
</form>
@if($q && $games->isEmpty())
<p class="text-slate-500">No games found for "{{ $q }}".</p>
@elseif($games->isNotEmpty())
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
@foreach($games as $game)
<x-game-card :game="$game" />
@endforeach
</div>
@endif
</div>
@endsection
+304
View File
@@ -0,0 +1,304 @@
@extends('layouts.app')
@php use Illuminate\Support\Str; @endphp
@section('title', $game->title . ' — GameLog')
@section('description', $game->summary ? Str::limit($game->summary, 155) : 'Track ' . $game->title . ' on GameLog.')
@section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
{{-- Main Info --}}
<div class="flex flex-col md:flex-row gap-8">
{{-- Cover --}}
<div class="flex-shrink-0">
<div class="w-48 md:w-56 rounded-xl overflow-hidden bg-[#16161d] shadow-2xl shadow-black/50 mx-auto md:mx-0">
@if($game->cover_url)
<img src="{{ $game->cover_url }}" alt="{{ $game->title }}" class="w-full">
@else
<div class="aspect-[3/4] flex items-center justify-center">
<svg class="w-16 h-16 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>
@endif
</div>
</div>
{{-- Details --}}
<div class="flex-1 min-w-0">
{{-- Title & Year --}}
<div class="mb-2">
@if($game->release_year)
<span class="text-sm text-slate-500 font-medium">{{ $game->release_year }}</span>
@endif
<h1 class="text-3xl sm:text-4xl font-bold text-white leading-tight mt-1">{{ $game->title }}</h1>
</div>
{{-- Rating --}}
<div class="flex items-center gap-4 mb-6">
@if($game->rating_avg)
<div class="flex items-center gap-1.5 bg-[#16161d] px-3 py-1.5 rounded-lg border border-white/10">
<svg class="w-4 h-4 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-white font-bold">{{ number_format($game->rating_avg, 1) }}</span>
<span class="text-slate-500 text-sm">/ 10</span>
</div>
<span class="text-sm text-slate-500">{{ number_format($game->rating_count) }} {{ Str::plural('rating', $game->rating_count) }}</span>
@else
<span class="text-sm text-slate-500">No ratings yet</span>
@endif
</div>
{{-- Genres & Platforms --}}
@if($game->genres->isNotEmpty())
<div class="flex flex-wrap gap-2 mb-3">
@foreach($game->genres as $genre)
<a href="{{ route('games.index', ['genre' => $genre->id]) }}"
class="px-2.5 py-1 text-xs bg-purple-900/30 text-purple-300 border border-purple-700/30 rounded-full hover:bg-purple-900/50 transition-colors">
{{ $genre->name }}
</a>
@endforeach
</div>
@endif
@if($game->platforms->isNotEmpty())
<div class="flex flex-wrap gap-2 mb-5">
@foreach($game->platforms as $platform)
<a href="{{ route('games.index', ['platform' => $platform->id]) }}"
class="px-2.5 py-1 text-xs bg-[#16161d] text-slate-400 border border-white/10 rounded-full hover:text-slate-200 transition-colors">
{{ $platform->name }}
</a>
@endforeach
</div>
@endif
{{-- Add to Library --}}
<div class="mb-6" x-data="libraryWidget({{ $game->id }}, '{{ $userGame?->status }}', {{ $userGame?->rating ?? 'null' }})">
@if($userGame)
{{-- Already in library --}}
<div class="flex flex-wrap items-center gap-3">
<div class="relative" x-data="{ open: false }">
<button @click="open = !open"
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm font-medium rounded-lg transition-colors">
<span x-text="statusLabel(status)"></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 left-0 mt-2 w-44 bg-[#16161d] border border-white/10 rounded-lg shadow-xl overflow-hidden z-10">
@foreach(['playing' => 'Playing', 'played' => 'Played', 'want_to_play' => 'Want to Play', 'on_hold' => 'On Hold', 'dropped' => 'Dropped'] as $s => $label)
<button @click="updateStatus('{{ $s }}', {{ $userGame->id }}); open = false"
class="w-full text-left px-4 py-2.5 text-sm hover:bg-white/5 transition-colors"
:class="status === '{{ $s }}' ? 'text-purple-400' : 'text-slate-300'">
{{ $label }}
</button>
@endforeach
<div class="border-t border-white/5"></div>
<button @click="removeFromLibrary({{ $userGame->id }})"
class="w-full text-left px-4 py-2.5 text-sm text-red-400 hover:bg-white/5 transition-colors">
Remove
</button>
</div>
</div>
{{-- Rating --}}
<div class="flex items-center gap-2">
<span class="text-sm text-slate-400">Your rating:</span>
<input type="number" min="1" max="10" x-model="rating"
@change="updateRating({{ $userGame->id }})"
class="w-16 bg-[#16161d] 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>
</div>
@else
@auth
<div class="flex flex-wrap gap-3">
<div class="relative" x-data="{ open: false }">
<button @click="open = !open"
class="flex items-center gap-2 px-5 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-semibold rounded-lg transition-colors">
+ Add to Library
<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 left-0 mt-2 w-44 bg-[#16161d] border border-white/10 rounded-lg shadow-xl overflow-hidden z-10">
@foreach(['playing' => 'Playing', 'played' => 'Played', 'want_to_play' => 'Want to Play', 'on_hold' => 'On Hold', 'dropped' => 'Dropped'] as $s => $label)
<button @click="addToLibrary('{{ $s }}'); open = false"
class="w-full text-left px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">
{{ $label }}
</button>
@endforeach
</div>
</div>
</div>
@else
<a href="{{ route('login') }}"
class="inline-flex items-center gap-2 px-5 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-semibold rounded-lg transition-colors">
Log in to Add to Library
</a>
@endauth
@endif
</div>
{{-- Companies --}}
@if($game->companies->isNotEmpty())
<div class="text-sm text-slate-500 space-y-1">
@php
$developers = $game->companies->where('pivot.role', 'developer');
$publishers = $game->companies->where('pivot.role', 'publisher');
@endphp
@if($developers->isNotEmpty())
<div><span class="text-slate-400">Developer:</span> {{ $developers->pluck('name')->join(', ') }}</div>
@endif
@if($publishers->isNotEmpty())
<div><span class="text-slate-400">Publisher:</span> {{ $publishers->pluck('name')->join(', ') }}</div>
@endif
</div>
@endif
</div>
</div>
{{-- Summary --}}
@if($game->summary)
<div class="mt-10 pt-8 border-t border-white/5">
<h2 class="text-lg font-semibold text-white mb-3">Summary</h2>
<p class="text-slate-400 leading-relaxed max-w-3xl">{{ $game->summary }}</p>
</div>
@endif
{{-- Screenshots --}}
@if($game->screenshots->isNotEmpty())
<div class="mt-10 pt-8 border-t border-white/5">
<h2 class="text-lg font-semibold text-white mb-4">Screenshots</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
@foreach($game->screenshots as $shot)
<a href="{{ $shot->url }}" target="_blank" class="block rounded-lg overflow-hidden bg-[#16161d] hover:ring-1 hover:ring-purple-500 transition-all">
<img src="{{ $shot->thumb_url ?? $shot->url }}" alt="Screenshot" class="w-full aspect-video object-cover">
</a>
@endforeach
</div>
</div>
@endif
{{-- Reviews --}}
<div class="mt-10 pt-8 border-t border-white/5">
<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="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>
</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>
@endif
</div>
@if($review->body)
<p class="text-slate-400 text-sm leading-relaxed">{{ $review->body }}</p>
@endif
</div>
@endforeach
</div>
@endif
</div>
</div>
@push('scripts')
<script>
function libraryWidget(gameId, initialStatus, initialRating) {
return {
gameId,
status: initialStatus,
rating: initialRating,
statusLabel(s) {
const labels = {
playing: 'Playing',
played: 'Played',
want_to_play: 'Want to Play',
on_hold: 'On Hold',
dropped: 'Dropped',
};
return labels[s] || 'In Library';
},
async addToLibrary(status) {
const res = await fetch('/library', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: JSON.stringify({ game_id: this.gameId, status }),
});
if (res.ok) {
this.status = status;
window.location.reload();
}
},
async updateStatus(status, userGameId) {
const res = await fetch(`/library/${userGameId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: JSON.stringify({ status }),
});
if (res.ok) this.status = status;
},
async updateRating(userGameId) {
await fetch(`/library/${userGameId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: JSON.stringify({ rating: this.rating }),
});
},
async removeFromLibrary(userGameId) {
const res = await fetch(`/library/${userGameId}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
});
if (res.ok) window.location.reload();
},
};
}
</script>
@endpush
@endsection
+23
View File
@@ -0,0 +1,23 @@
@extends('layouts.app')
@section('title', 'Top Rated Games — GameLog')
@section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
<h1 class="text-2xl font-bold text-white mb-2">Top Rated</h1>
<p class="text-slate-500 mb-8">The highest-rated games on GameLog.</p>
@if($games->isEmpty())
<div class="text-center py-24 text-slate-500">
<p>No games rated yet.</p>
</div>
@else
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
@foreach($games as $game)
<x-game-card :game="$game" />
@endforeach
</div>
<div class="mt-8">{{ $games->links() }}</div>
@endif
</div>
@endsection
+92
View File
@@ -0,0 +1,92 @@
@extends('layouts.app')
@section('title', 'GameLog — Track Every Game You Play')
@section('content')
{{-- Hero --}}
<section class="bg-gradient-to-b from-[#1a1025] to-[#0f0f13] py-20 px-4">
<div class="max-w-3xl mx-auto text-center">
<h1 class="text-4xl sm:text-5xl font-bold text-white leading-tight mb-4">
Track Every Game<br>
<span class="text-purple-400">You Play</span>
</h1>
<p class="text-lg text-slate-400 mb-8">
Rate games, build your library, discover what to play next.
</p>
<form action="{{ route('games.search') }}" method="GET" class="max-w-xl mx-auto">
<div class="relative">
<svg class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 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"/>
</svg>
<input type="text" name="q" placeholder="Search for a game…"
class="w-full bg-[#16161d] border border-white/10 rounded-xl pl-12 pr-4 py-4 text-base text-slate-200 placeholder-slate-500 focus:outline-none focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 transition">
<button type="submit" class="absolute right-3 top-1/2 -translate-y-1/2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm font-medium rounded-lg transition-colors">
Search
</button>
</div>
</form>
@guest
<div class="mt-8 flex items-center justify-center gap-4">
<a href="{{ route('register') }}" class="px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white font-semibold rounded-lg transition-colors">
Create Free Account
</a>
<a href="{{ route('games.index') }}" class="px-6 py-3 border border-white/10 hover:border-white/20 text-slate-300 font-semibold rounded-lg transition-colors">
Browse Games
</a>
</div>
@endguest
</div>
</section>
{{-- Top Rated --}}
@if($topRated->isNotEmpty())
<section class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-white">Top Rated</h2>
<a href="{{ route('games.top-rated') }}" class="text-sm text-purple-400 hover:text-purple-300 transition-colors">
See all →
</a>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
@foreach($topRated as $game)
<x-game-card :game="$game" />
@endforeach
</div>
</section>
@endif
{{-- New Releases --}}
@if($newReleases->isNotEmpty())
<section class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 border-t border-white/5">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-white">New Releases</h2>
<a href="{{ route('games.new-releases') }}" class="text-sm text-purple-400 hover:text-purple-300 transition-colors">
See all →
</a>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
@foreach($newReleases as $game)
<x-game-card :game="$game" />
@endforeach
</div>
</section>
@endif
{{-- Empty state --}}
@if($topRated->isEmpty() && $newReleases->isEmpty())
<section class="max-w-3xl mx-auto px-4 py-24 text-center">
<div class="text-slate-500 mb-4">
<svg class="w-16 h-16 mx-auto mb-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>
<p class="text-lg text-slate-400">No games in the database yet.</p>
<p class="text-sm mt-2">Run <code class="bg-[#16161d] px-2 py-1 rounded text-purple-400">php artisan games:import</code> to import games from IGDB.</p>
</div>
</section>
@endif
@endsection
+128 -27
View File
@@ -1,36 +1,137 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark h-full">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}"> <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
<title>{{ config('app.name', 'Laravel') }}</title> <!-- Open Graph -->
<meta property="og:title" content="@yield('og_title', 'GameLog')">
<meta property="og:description" content="@yield('og_description', 'Track every game you play.')">
<meta property="og:type" content="@yield('og_type', 'website')">
<meta property="og:url" content="@yield('og_url', url()->current())">
<!-- Fonts --> <!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net"> <link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" /> <link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet" />
<!-- Scripts --> @vite(['resources/css/app.css', 'resources/js/app.js'])
@vite(['resources/css/app.css', 'resources/js/app.js']) @stack('styles')
</head> </head>
<body class="font-sans antialiased"> <body class="h-full bg-[#0f0f13] text-slate-200 antialiased font-sans">
<div class="min-h-screen bg-gray-100 dark:bg-gray-900">
@include('layouts.navigation')
<!-- Page Heading --> <!-- Navigation -->
@isset($header) <nav class="bg-[#16161d] border-b border-white/5 sticky top-0 z-50">
<header class="bg-white dark:bg-gray-800 shadow"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8"> <div class="flex items-center justify-between h-16">
{{ $header }}
<!-- 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>
</header> </div>
@endisset
<!-- Page Content --> <!-- Center: Search -->
<main> <div class="flex-1 max-w-md mx-8 hidden md:block">
{{ $slot }} <form action="{{ route('games.search') }}" method="GET">
</main> <div class="relative">
<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"/>
</svg>
<input type="text" name="q" value="{{ request('q') }}" placeholder="Search games…"
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">
</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>
@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> </div>
</body> </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')
</body>
</html> </html>
+48
View File
@@ -0,0 +1,48 @@
@extends('layouts.app')
@section('title', ($user->username ?? $user->name) . "'s Library — 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">
<div>
<h1 class="text-2xl font-bold text-white">
<a href="{{ route('users.show', $user->username) }}" class="hover:text-purple-400 transition-colors">
{{ $user->username ?? $user->name }}
</a>'s Library
</h1>
</div>
</div>
{{-- Status Tabs --}}
<div class="flex flex-wrap gap-2 mb-8 border-b border-white/5 pb-4">
@php
$tabs = ['' => 'All', 'playing' => 'Playing', 'played' => 'Played', 'want_to_play' => 'Want to Play', 'on_hold' => 'On Hold', 'dropped' => 'Dropped'];
@endphp
@foreach($tabs as $tabStatus => $tabLabel)
<a href="{{ route('users.library', ['user' => $user->username, 'status' => $tabStatus ?: null]) }}"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors
{{ $status === $tabStatus || (!$status && !$tabStatus)
? 'bg-purple-600 text-white'
: 'text-slate-400 hover:text-slate-200 hover:bg-white/5' }}">
{{ $tabLabel }}
</a>
@endforeach
</div>
@if($entries->isEmpty())
<div class="text-center py-24 text-slate-500">
<p class="text-lg">No games here yet.</p>
</div>
@else
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
@foreach($entries as $entry)
<x-game-card :game="$entry->game" />
@endforeach
</div>
<div class="mt-8">{{ $entries->links() }}</div>
@endif
</div>
@endsection
+71
View File
@@ -0,0 +1,71 @@
@extends('layouts.app')
@section('title', ($user->username ?? $user->name) . ' — GameLog')
@section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
{{-- Profile Header --}}
<div class="flex items-start gap-6 mb-10">
@if($user->avatar)
<img src="{{ $user->avatar }}" class="w-20 h-20 rounded-full object-cover border-2 border-white/10" alt="">
@else
<div class="w-20 h-20 rounded-full bg-purple-700 flex items-center justify-center text-2xl font-bold text-white flex-shrink-0">
{{ strtoupper(substr($user->name, 0, 1)) }}
</div>
@endif
<div class="flex-1">
<h1 class="text-2xl font-bold text-white">{{ $user->username ?? $user->name }}</h1>
@if($user->username && $user->name !== $user->username)
<p class="text-slate-500 text-sm">{{ $user->name }}</p>
@endif
@if($user->bio)
<p class="text-slate-400 mt-2 max-w-xl">{{ $user->bio }}</p>
@endif
{{-- Stats --}}
<div class="flex flex-wrap gap-6 mt-4">
<div class="text-center">
<div class="text-xl font-bold text-white">{{ number_format($stats['total']) }}</div>
<div class="text-xs text-slate-500 uppercase tracking-wide">Games</div>
</div>
<div class="text-center">
<div class="text-xl font-bold text-white">{{ number_format($stats['played']) }}</div>
<div class="text-xs text-slate-500 uppercase tracking-wide">Played</div>
</div>
<div class="text-center">
<div class="text-xl font-bold text-white">{{ number_format($stats['playing']) }}</div>
<div class="text-xs text-slate-500 uppercase tracking-wide">Playing</div>
</div>
<div class="text-center">
<div class="text-xl font-bold text-white">{{ number_format($stats['want_to_play']) }}</div>
<div class="text-xs text-slate-500 uppercase tracking-wide">Want to Play</div>
</div>
</div>
</div>
<div class="flex gap-2">
<a href="{{ route('users.library', $user->username) }}"
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>
</div>
</div>
{{-- Recently Played --}}
@if($recentlyPlayed->isNotEmpty())
<div>
<h2 class="text-lg font-semibold text-white mb-4">Recently Played</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
@foreach($recentlyPlayed as $entry)
<x-game-card :game="$entry->game" />
@endforeach
</div>
</div>
@else
<p class="text-slate-500">No games tracked yet.</p>
@endif
</div>
@endsection
+26 -3
View File
@@ -1,16 +1,39 @@
<?php <?php
use App\Http\Controllers\GameController;
use App\Http\Controllers\HomeController;
use App\Http\Controllers\LibraryController;
use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProfileController;
use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', function () { // Home
return view('welcome'); Route::get('/', [HomeController::class, 'index'])->name('home');
// Games (public)
Route::get('/games', [GameController::class, 'index'])->name('games.index');
Route::get('/games/search', [GameController::class, 'search'])->name('games.search');
Route::get('/top-rated', [GameController::class, 'topRated'])->name('games.top-rated');
Route::get('/new-releases', [GameController::class, 'newReleases'])->name('games.new-releases');
Route::get('/games/{game:slug}', [GameController::class, 'show'])->name('games.show');
// User profiles (public)
Route::get('/users/{user:username}', [UserController::class, 'show'])->name('users.show');
Route::get('/users/{user:username}/library', [UserController::class, 'library'])->name('users.library');
// Library (auth required)
Route::middleware('auth')->group(function () {
Route::post('/library', [LibraryController::class, 'store'])->name('library.store');
Route::put('/library/{userGame}', [LibraryController::class, 'update'])->name('library.update');
Route::delete('/library/{userGame}', [LibraryController::class, 'destroy'])->name('library.destroy');
}); });
// Dashboard (Breeze default, keep for now)
Route::get('/dashboard', function () { Route::get('/dashboard', function () {
return view('dashboard'); return redirect()->route('home');
})->middleware(['auth', 'verified'])->name('dashboard'); })->middleware(['auth', 'verified'])->name('dashboard');
// Profile (Breeze)
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');