Core implementation: controllers, views, routes, models, import command
This commit is contained in:
@@ -25,3 +25,4 @@ _ide_helper.php
|
|||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user