diff --git a/.gitignore b/.gitignore
index 7be55e2..136d4e9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,4 @@ _ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
+.claude/
diff --git a/app/Console/Commands/ImportGamesCommand.php b/app/Console/Commands/ImportGamesCommand.php
new file mode 100644
index 0000000..d2094e3
--- /dev/null
+++ b/app/Console/Commands/ImportGamesCommand.php
@@ -0,0 +1,146 @@
+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,
+ ]);
+ }
+ }
+ }
+}
diff --git a/app/Http/Controllers/GameController.php b/app/Http/Controllers/GameController.php
new file mode 100644
index 0000000..f1d3987
--- /dev/null
+++ b/app/Http/Controllers/GameController.php
@@ -0,0 +1,106 @@
+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'));
+ }
+}
diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php
new file mode 100644
index 0000000..4bdebaa
--- /dev/null
+++ b/app/Http/Controllers/HomeController.php
@@ -0,0 +1,27 @@
+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'));
+ }
+}
diff --git a/app/Http/Controllers/LibraryController.php b/app/Http/Controllers/LibraryController.php
new file mode 100644
index 0000000..932076b
--- /dev/null
+++ b/app/Http/Controllers/LibraryController.php
@@ -0,0 +1,73 @@
+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.');
+ }
+}
diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php
new file mode 100644
index 0000000..7d8ba59
--- /dev/null
+++ b/app/Http/Controllers/UserController.php
@@ -0,0 +1,42 @@
+ $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'));
+ }
+}
diff --git a/app/Models/Company.php b/app/Models/Company.php
index 5323f59..ae37309 100644
--- a/app/Models/Company.php
+++ b/app/Models/Company.php
@@ -3,8 +3,14 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Company extends Model
{
- //
+ protected $fillable = ['igdb_id', 'name', 'slug'];
+
+ public function games(): BelongsToMany
+ {
+ return $this->belongsToMany(Game::class, 'game_company')->withPivot('role');
+ }
}
diff --git a/app/Models/GameScreenshot.php b/app/Models/GameScreenshot.php
index a51bc8a..e27470b 100644
--- a/app/Models/GameScreenshot.php
+++ b/app/Models/GameScreenshot.php
@@ -3,8 +3,14 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GameScreenshot extends Model
{
- //
+ protected $fillable = ['game_id', 'url', 'thumb_url', 'order'];
+
+ public function game(): BelongsTo
+ {
+ return $this->belongsTo(Game::class);
+ }
}
diff --git a/app/Models/Genre.php b/app/Models/Genre.php
index 9b48517..0875f4c 100644
--- a/app/Models/Genre.php
+++ b/app/Models/Genre.php
@@ -3,8 +3,14 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Genre extends Model
{
- //
+ protected $fillable = ['igdb_id', 'name', 'slug'];
+
+ public function games(): BelongsToMany
+ {
+ return $this->belongsToMany(Game::class, 'game_genre');
+ }
}
diff --git a/app/Models/Platform.php b/app/Models/Platform.php
index aeb3af9..7286b0c 100644
--- a/app/Models/Platform.php
+++ b/app/Models/Platform.php
@@ -3,8 +3,14 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Platform extends Model
{
- //
+ protected $fillable = ['igdb_id', 'name', 'slug', 'abbreviation'];
+
+ public function games(): BelongsToMany
+ {
+ return $this->belongsToMany(Game::class, 'game_platform');
+ }
}
diff --git a/app/Policies/UserGamePolicy.php b/app/Policies/UserGamePolicy.php
new file mode 100644
index 0000000..70de3a5
--- /dev/null
+++ b/app/Policies/UserGamePolicy.php
@@ -0,0 +1,19 @@
+id === $userGame->user_id;
+ }
+
+ public function delete(User $user, UserGame $userGame): bool
+ {
+ return $user->id === $userGame->user_id;
+ }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 452e6b6..107d503 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -2,6 +2,9 @@
namespace App\Providers;
+use App\Models\UserGame;
+use App\Policies\UserGamePolicy;
+use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -19,6 +22,6 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
- //
+ Gate::policy(UserGame::class, UserGamePolicy::class);
}
}
diff --git a/app/Services/IgdbService.php b/app/Services/IgdbService.php
index 684a132..6b5758e 100644
--- a/app/Services/IgdbService.php
+++ b/app/Services/IgdbService.php
@@ -102,7 +102,12 @@ class IgdbService
{
return $this->query('games', "
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;
where rating != null & rating_count > 100 & cover != null;
sort rating_count desc;
@@ -121,7 +126,12 @@ class IgdbService
return $this->query('games', "
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;
where first_release_date >= {$timestamp} & first_release_date <= {$now} & cover != null;
sort first_release_date desc;
diff --git a/resources/views/components/game-card.blade.php b/resources/views/components/game-card.blade.php
new file mode 100644
index 0000000..fd03864
--- /dev/null
+++ b/resources/views/components/game-card.blade.php
@@ -0,0 +1,38 @@
+@props(['game'])
+
+
+
+
+ @else
+
+ {{ $game->title }}
+
+
No games found.
+ Clear filters +The latest games in the GameLog database.
+ + @if($games->isEmpty()) +No new releases found.
+No games found for "{{ $q }}".
+ @elseif($games->isNotEmpty()) +{{ $game->summary }}
+No reviews yet. Be the first!
+ @else +{{ $review->body }}
+ @endif +The highest-rated games on GameLog.
+ + @if($games->isEmpty()) +No games rated yet.
++ Rate games, build your library, discover what to play next. +
+ + + + @guest + + @endguest +No games in the database yet.
+Run php artisan games:import to import games from IGDB.
No games here yet.
+{{ $user->name }}
+ @endif + @if($user->bio) +{{ $user->bio }}
+ @endif + + {{-- Stats --}} +No games tracked yet.
+ @endif + +