Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e5a55dd05 |
+1
-1
@@ -2,7 +2,7 @@ APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=https://lorekeepers.games
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
APP_NAME=LoreKeepers
|
||||
APP_ENV=production
|
||||
APP_KEY=base64:cLe6IJ0q4VCVaBM6RD6QgCE+lrIDGHVujZDy4os3xmo=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://lorekeepers.games
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=gatekeep_rigsby
|
||||
DB_USERNAME=gatekeep_rigsby
|
||||
DB_PASSWORD=hGvWsxa9Pj6H9QFg9qGZ
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="glados@lorekeepers.games"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
IGDB_CLIENT_ID=936pyeouqrqsuj71afndnck5et1pk4
|
||||
IGDB_CLIENT_SECRET=t8jdqii3cj1fzotcwknokc269d1qnr
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Game;
|
||||
use App\Services\IgdbService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class BackfillExtendedDataCommand extends Command
|
||||
{
|
||||
protected $signature = 'igdb:backfill-extended
|
||||
{--limit=100 : Max number of games to process}
|
||||
{--offset=0 : Skip the first N games (ordered by id)}';
|
||||
|
||||
protected $description = 'Backfill extended IGDB data (collections, modes, themes, videos, etc.) for existing games';
|
||||
|
||||
public function handle(IgdbService $igdb): int
|
||||
{
|
||||
$limit = (int) $this->option('limit');
|
||||
$offset = (int) $this->option('offset');
|
||||
|
||||
$games = Game::whereNotNull('igdb_id')
|
||||
->orderBy('id')
|
||||
->skip($offset)
|
||||
->take($limit)
|
||||
->get();
|
||||
|
||||
$total = $games->count();
|
||||
|
||||
if ($total === 0) {
|
||||
$this->info('No games found to process.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Processing {$total} games (offset: {$offset})…");
|
||||
$this->newLine();
|
||||
|
||||
$n = 0;
|
||||
foreach ($games->chunk(10) as $batch) {
|
||||
$igdbIds = $batch->pluck('igdb_id')->all();
|
||||
|
||||
try {
|
||||
$igdbResults = $igdb->getGamesByIds($igdbIds);
|
||||
} catch (\Exception $e) {
|
||||
$this->error('IGDB request failed: ' . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$indexed = collect($igdbResults)->keyBy('id');
|
||||
|
||||
foreach ($batch as $game) {
|
||||
$n++;
|
||||
$igdbData = $indexed->get($game->igdb_id);
|
||||
|
||||
if (!$igdbData) {
|
||||
$this->warn(" [{$n}/{$total}] No IGDB data for: {$game->title} (igdb_id={$game->igdb_id})");
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$igdb->syncExtendedData($game, $igdbData);
|
||||
$this->line(" [{$n}/{$total}] Processed: {$game->title}");
|
||||
} catch (\Exception $e) {
|
||||
$this->warn(" [{$n}/{$total}] Failed {$game->title}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
usleep(250000); // 250ms between IGDB batches
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Extended data sync complete. Resolving similar_games pivot…');
|
||||
|
||||
$this->resolveSimilarGames();
|
||||
|
||||
$this->info('Done.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveSimilarGames(): void
|
||||
{
|
||||
$resolved = 0;
|
||||
Game::whereNotNull('similar_game_igdb_ids')->chunk(100, function ($games) use (&$resolved) {
|
||||
foreach ($games as $game) {
|
||||
$ids = $game->similar_game_igdb_ids;
|
||||
if (empty($ids)) continue;
|
||||
|
||||
$similarIds = Game::whereIn('igdb_id', $ids)->pluck('id');
|
||||
$game->similarGames()->sync($similarIds);
|
||||
$resolved++;
|
||||
}
|
||||
});
|
||||
$this->line(" Resolved similar_games for {$resolved} games.");
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Concerns\UpsertsGames;
|
||||
use App\Services\IgdbService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class BackfillGamesCommand extends Command
|
||||
{
|
||||
use UpsertsGames;
|
||||
|
||||
protected $signature = 'games:backfill
|
||||
{--start= : Override the starting offset (ignores saved progress)}
|
||||
{--total=50000 : Stop after importing this many games total}
|
||||
{--batch=500 : Games per IGDB request (max 500)}
|
||||
{--sleep=2 : Seconds to sleep between batches}
|
||||
{--reset : Clear saved progress and start from 0}
|
||||
{--min-ratings=5 : Minimum rating_count to include}';
|
||||
|
||||
protected $description = 'Bulk-import games from IGDB, resuming from last saved offset if interrupted';
|
||||
|
||||
private const PROGRESS_KEY = 'games_backfill_offset';
|
||||
|
||||
public function handle(IgdbService $igdb): int
|
||||
{
|
||||
$batch = min((int) $this->option('batch'), 500);
|
||||
$total = (int) $this->option('total');
|
||||
$sleep = (int) $this->option('sleep');
|
||||
$minRatings = (int) $this->option('min-ratings');
|
||||
|
||||
if ($this->option('reset')) {
|
||||
Cache::forget(self::PROGRESS_KEY);
|
||||
$this->info('Progress reset.');
|
||||
}
|
||||
|
||||
$startOption = $this->option('start');
|
||||
$offset = $startOption !== null
|
||||
? (int) $startOption
|
||||
: (int) Cache::get(self::PROGRESS_KEY, 0);
|
||||
|
||||
$this->info("Starting backfill — offset: {$offset}, target: {$total} games, batch: {$batch}, sleep: {$sleep}s");
|
||||
$this->newLine();
|
||||
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
$batchNum = 0;
|
||||
|
||||
while ($imported < $total) {
|
||||
$batchNum++;
|
||||
$limit = min($batch, $total - $imported);
|
||||
|
||||
$this->line("Batch #{$batchNum} — offset {$offset}, fetching {$limit}…");
|
||||
|
||||
try {
|
||||
$results = $igdb->getPopularGamesForBackfill($limit, $offset, $minRatings);
|
||||
} catch (\Exception $e) {
|
||||
$this->error('IGDB request failed: ' . $e->getMessage());
|
||||
$this->warn("Progress saved at offset {$offset}. Re-run to resume.");
|
||||
Cache::put(self::PROGRESS_KEY, $offset);
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (empty($results)) {
|
||||
$this->info('IGDB returned no more results. Backfill complete.');
|
||||
Cache::forget(self::PROGRESS_KEY);
|
||||
break;
|
||||
}
|
||||
|
||||
$batchImported = 0;
|
||||
foreach ($results as $data) {
|
||||
try {
|
||||
$this->upsertGame($data, $igdb);
|
||||
$batchImported++;
|
||||
} catch (\Exception $e) {
|
||||
$skipped++;
|
||||
$this->warn(" Skipped igdb_id={$data['id']}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$imported += $batchImported;
|
||||
$offset += count($results);
|
||||
|
||||
Cache::put(self::PROGRESS_KEY, $offset);
|
||||
|
||||
$this->line(" → {$batchImported} upserted | total so far: {$imported} | next offset: {$offset}");
|
||||
|
||||
if ($imported < $total && count($results) === $limit) {
|
||||
sleep($sleep);
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Backfill finished. Imported/updated: {$imported}, skipped: {$skipped}.");
|
||||
|
||||
if ($imported >= $total) {
|
||||
$this->comment("Reached --total={$total}. Run again (or with a higher --total) to continue from offset {$offset}.");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Concerns;
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\Game;
|
||||
use App\Models\GameScreenshot;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Platform;
|
||||
use App\Services\IgdbService;
|
||||
|
||||
trait UpsertsGames
|
||||
{
|
||||
protected 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',
|
||||
]
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$igdb->syncExtendedData($game, $data);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Game;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Spatie\Sitemap\Sitemap;
|
||||
use Spatie\Sitemap\Tags\Url as TagsUrl;
|
||||
|
||||
class GenerateSitemapCommand extends Command
|
||||
{
|
||||
protected $signature = 'sitemap:generate';
|
||||
protected $description = 'Generate the sitemap.xml file';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
URL::forceScheme('https');
|
||||
$sitemap = Sitemap::create()
|
||||
->add(TagsUrl::create('/')->setPriority(1.0)->setChangeFrequency(TagsUrl::CHANGE_FREQUENCY_DAILY))
|
||||
->add(TagsUrl::create('/games')->setPriority(0.9)->setChangeFrequency(TagsUrl::CHANGE_FREQUENCY_DAILY))
|
||||
->add(TagsUrl::create('/top-rated')->setPriority(0.8)->setChangeFrequency(TagsUrl::CHANGE_FREQUENCY_WEEKLY))
|
||||
->add(TagsUrl::create('/new-releases')->setPriority(0.8)->setChangeFrequency(TagsUrl::CHANGE_FREQUENCY_DAILY));
|
||||
|
||||
Game::select('slug', 'updated_at')->chunk(500, function ($games) use ($sitemap) {
|
||||
foreach ($games as $game) {
|
||||
$sitemap->add(
|
||||
TagsUrl::create("/games/{$game->slug}")
|
||||
->setLastModificationDate($game->updated_at)
|
||||
->setPriority(0.7)
|
||||
->setChangeFrequency(TagsUrl::CHANGE_FREQUENCY_WEEKLY)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
User::where('is_private', false)
|
||||
->select('slug', 'updated_at')
|
||||
->chunk(500, function ($users) use ($sitemap) {
|
||||
foreach ($users as $user) {
|
||||
$sitemap->add(
|
||||
TagsUrl::create("/users/{$user->slug}")
|
||||
->setLastModificationDate($user->updated_at)
|
||||
->setPriority(0.5)
|
||||
->setChangeFrequency(TagsUrl::CHANGE_FREQUENCY_WEEKLY)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
$sitemap->writeToFile(public_path('sitemap.xml'));
|
||||
|
||||
$this->info('Sitemap generated at public/sitemap.xml');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -2,43 +2,32 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Concerns\UpsertsGames;
|
||||
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
|
||||
{
|
||||
use UpsertsGames;
|
||||
protected $signature = 'games:import {--limit=100} {--offset=0} {--popular} {--new} {--year=} {--platform=}';
|
||||
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');
|
||||
$year = $this->option('year') ? (int) $this->option('year') : null;
|
||||
$platform = $this->option('platform') ? (int) $this->option('platform') : null;
|
||||
$limit = (int) $this->option('limit');
|
||||
$offset = (int) $this->option('offset');
|
||||
|
||||
if ($year && $platform) {
|
||||
$this->error('Use --year or --platform, not both.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$label = match (true) {
|
||||
$year !== null => "year {$year}",
|
||||
$platform !== null => "platform IGDB ID {$platform}",
|
||||
$this->option('new') => 'new releases',
|
||||
default => "popular (offset: {$offset})",
|
||||
};
|
||||
$this->info("Fetching {$limit} games from IGDB ({$label})…");
|
||||
$this->info("Fetching {$limit} games from IGDB (offset: {$offset})…");
|
||||
|
||||
try {
|
||||
$results = match (true) {
|
||||
$year !== null => $igdb->getGamesByYear($year, $limit, $offset),
|
||||
$platform !== null => $igdb->getGamesByPlatform($platform, $limit, $offset),
|
||||
$this->option('new') => $igdb->getNewReleases($limit),
|
||||
default => $igdb->getPopularGames($limit, $offset),
|
||||
};
|
||||
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;
|
||||
@@ -71,4 +60,87 @@ class ImportGamesCommand extends Command
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Activity;
|
||||
|
||||
class ActivityController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$user = auth()->user();
|
||||
$followingIds = $user->following()->pluck('users.id')->push($user->id);
|
||||
|
||||
$activities = Activity::with(['user', 'subject'])
|
||||
->whereIn('user_id', $followingIds)
|
||||
->latest('created_at')
|
||||
->paginate(40);
|
||||
|
||||
return view('activity.index', compact('activities'));
|
||||
}
|
||||
|
||||
public function global()
|
||||
{
|
||||
$activities = Activity::with(['user', 'subject'])
|
||||
->latest('created_at')
|
||||
->paginate(40);
|
||||
|
||||
return view('activity.index', compact('activities'));
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Game;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class GameApiController extends Controller
|
||||
{
|
||||
private function formatGame(Game $game): array
|
||||
{
|
||||
return [
|
||||
'id' => $game->id,
|
||||
'title' => $game->title,
|
||||
'slug' => $game->slug,
|
||||
'cover_url' => $game->cover_url,
|
||||
'release_year' => $game->release_year,
|
||||
'release_date' => $game->release_date?->toDateString(),
|
||||
'rating_avg' => $game->rating_avg ? (float) $game->rating_avg : null,
|
||||
'rating_count' => $game->rating_count,
|
||||
'library_count' => $game->library_count,
|
||||
'genres' => $game->genres->pluck('name'),
|
||||
'platforms' => $game->platforms->pluck('name'),
|
||||
];
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Game::with(['genres', 'platforms'])->published();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
match ($request->get('sort', 'popular')) {
|
||||
'rating' => $query->orderByDesc('rating_avg'),
|
||||
'release' => $query->orderByDesc('release_date'),
|
||||
'title' => $query->orderBy('title'),
|
||||
default => $query->orderByDesc('library_count'),
|
||||
};
|
||||
|
||||
$paginator = $query->paginate(20);
|
||||
|
||||
return response()->json([
|
||||
'data' => $paginator->map(fn ($game) => $this->formatGame($game))->values(),
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Game $game): JsonResponse
|
||||
{
|
||||
$game->load(['genres', 'platforms', 'companies', 'screenshots', 'reviews.user']);
|
||||
|
||||
return response()->json([
|
||||
'data' => array_merge($this->formatGame($game), [
|
||||
'description' => $game->summary,
|
||||
'companies' => $game->companies->map(fn ($c) => [
|
||||
'name' => $c->name,
|
||||
'role' => $c->pivot->role,
|
||||
]),
|
||||
'screenshots' => $game->screenshots->pluck('url'),
|
||||
'reviews' => $game->reviews->take(5)->map(fn ($r) => [
|
||||
'user' => $r->user->slug,
|
||||
'body' => $r->body,
|
||||
'rating' => $r->rating,
|
||||
'created_at' => $r->created_at->toIso8601String(),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function search(Request $request): JsonResponse
|
||||
{
|
||||
$q = trim((string) $request->get('q', ''));
|
||||
|
||||
if (mb_strlen($q) < 2) {
|
||||
return response()->json(['error' => 'Query must be at least 2 characters.'], 422);
|
||||
}
|
||||
|
||||
$games = Game::with(['genres', 'platforms'])
|
||||
->where('title', 'like', '%' . $q . '%')
|
||||
->published()
|
||||
->orderBy('title')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'data' => $games->map(fn ($game) => $this->formatGame($game))->values(),
|
||||
'meta' => ['total' => $games->count()],
|
||||
]);
|
||||
}
|
||||
|
||||
public function topRated(Request $request): JsonResponse
|
||||
{
|
||||
$paginator = Game::with(['genres', 'platforms'])
|
||||
->published()
|
||||
->where('rating_count', '>=', 1)
|
||||
->orderByDesc('rating_avg')
|
||||
->orderByDesc('rating_count')
|
||||
->paginate(20);
|
||||
|
||||
return response()->json([
|
||||
'data' => $paginator->map(fn ($game) => $this->formatGame($game))->values(),
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function newReleases(Request $request): JsonResponse
|
||||
{
|
||||
$paginator = Game::with(['genres', 'platforms'])
|
||||
->published()
|
||||
->where('release_date', '<=', now())
|
||||
->orderByDesc('release_date')
|
||||
->paginate(20);
|
||||
|
||||
return response()->json([
|
||||
'data' => $paginator->map(fn ($game) => $this->formatGame($game))->values(),
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Platform;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class MetaApiController extends Controller
|
||||
{
|
||||
public function genres(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'data' => Genre::orderBy('name')->get(['id', 'name', 'slug']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function platforms(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'data' => Platform::orderBy('name')->get(['id', 'name', 'slug']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UserApiController extends Controller
|
||||
{
|
||||
public function show(User $user): JsonResponse
|
||||
{
|
||||
$library = $user->library();
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'username' => $user->slug,
|
||||
'name' => $user->name,
|
||||
'avatar_url' => $user->avatar,
|
||||
'joined_at' => $user->created_at->toIso8601String(),
|
||||
'stats' => [
|
||||
'played' => (clone $library)->where('status', 'played')->count(),
|
||||
'playing' => (clone $library)->where('status', 'playing')->count(),
|
||||
'want_to_play' => (clone $library)->where('status', 'want_to_play')->count(),
|
||||
'total' => $user->library()->count(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function library(User $user, Request $request): JsonResponse
|
||||
{
|
||||
$paginator = $user->library()
|
||||
->with('game')
|
||||
->orderByDesc('updated_at')
|
||||
->paginate(20);
|
||||
|
||||
return response()->json([
|
||||
'data' => $paginator->map(fn ($ug) => [
|
||||
'game' => [
|
||||
'title' => $ug->game->title,
|
||||
'slug' => $ug->game->slug,
|
||||
'cover_url' => $ug->game->cover_url,
|
||||
],
|
||||
'status' => $ug->status,
|
||||
'rating' => $ug->rating,
|
||||
'updated_at' => $ug->updated_at->toIso8601String(),
|
||||
])->values(),
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ApiKey;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class ApiKeyController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$keys = auth()->user()->apiKeys()->latest()->get();
|
||||
return view('settings.api-keys', compact('keys'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate(['name' => 'required|string|max:50']);
|
||||
|
||||
$apiKey = ApiKey::generate(auth()->user(), $request->name);
|
||||
|
||||
return redirect()->route('settings.api-keys')->with('new_api_key', $apiKey->key);
|
||||
}
|
||||
|
||||
public function destroy(ApiKey $apiKey)
|
||||
{
|
||||
Gate::authorize('delete', $apiKey);
|
||||
$apiKey->delete();
|
||||
return redirect()->route('settings.api-keys')->with('success', 'API key deleted.');
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
@@ -37,17 +36,9 @@ class RegisteredUserController extends Controller
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$base = Str::slug($request->name);
|
||||
$slug = $base;
|
||||
$i = 1;
|
||||
while (User::where('slug', $slug)->exists()) {
|
||||
$slug = $base . '-' . $i++;
|
||||
}
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'slug' => $slug,
|
||||
'email' => $request->email,
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
class CompareController extends Controller
|
||||
{
|
||||
public function show(User $user)
|
||||
{
|
||||
if (auth()->id() === $user->id) {
|
||||
return redirect()->route('users.show', $user->slug);
|
||||
}
|
||||
|
||||
$myGames = auth()->user()->library()->with('game')->get()->keyBy('game_id');
|
||||
$theirGames = $user->library()->with('game')->get()->keyBy('game_id');
|
||||
|
||||
$myIds = $myGames->keys()->values();
|
||||
$theirIds = $theirGames->keys()->values();
|
||||
|
||||
$bothIds = $myIds->intersect($theirIds)->values();
|
||||
$theyHaveNotYouIds = $theirIds->diff($myIds)->values();
|
||||
$youHaveNotThemIds = $myIds->diff($theirIds)->values();
|
||||
|
||||
$both = $bothIds->map(fn($id) => [
|
||||
'game' => $myGames[$id]->game,
|
||||
'myEntry' => $myGames[$id],
|
||||
'theirEntry' => $theirGames[$id],
|
||||
])->filter(fn($item) => $item['game'] !== null)->values();
|
||||
|
||||
$theyHaveNotYou = $theyHaveNotYouIds
|
||||
->map(fn($id) => $theirGames[$id])
|
||||
->filter(fn($ug) => $ug->game !== null)
|
||||
->values();
|
||||
|
||||
$youHaveNotThem = $youHaveNotThemIds
|
||||
->map(fn($id) => $myGames[$id])
|
||||
->filter(fn($ug) => $ug->game !== null)
|
||||
->values();
|
||||
|
||||
// Average rating diff on games both rated
|
||||
$diffSum = 0;
|
||||
$diffCount = 0;
|
||||
foreach ($bothIds as $id) {
|
||||
$myRating = $myGames[$id]->rating;
|
||||
$theirRating = $theirGames[$id]->rating;
|
||||
if ($myRating !== null && $theirRating !== null) {
|
||||
$diffSum += abs($myRating - $theirRating);
|
||||
$diffCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'both_count' => $bothIds->count(),
|
||||
'they_not_you' => $theyHaveNotYouIds->count(),
|
||||
'you_not_them' => $youHaveNotThemIds->count(),
|
||||
'avg_rating_diff' => $diffCount > 0 ? round($diffSum / $diffCount, 1) : null,
|
||||
];
|
||||
|
||||
return view('compare.show', compact('user', 'both', 'theyHaveNotYou', 'youHaveNotThem', 'stats'));
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,5 @@ namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use \Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
//
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class FollowController extends Controller
|
||||
{
|
||||
public function store(User $user): JsonResponse
|
||||
{
|
||||
$me = auth()->user();
|
||||
|
||||
if ($me->id === $user->id) {
|
||||
return response()->json(['error' => 'Cannot follow yourself'], 422);
|
||||
}
|
||||
|
||||
if (!$me->following()->where('following_id', $user->id)->exists()) {
|
||||
$me->following()->attach($user->id);
|
||||
|
||||
$user->notifications()->create([
|
||||
'id' => Str::uuid(),
|
||||
'type' => 'new_follower',
|
||||
'data' => json_encode([
|
||||
'follower_id' => $me->id,
|
||||
'follower_slug' => $me->slug,
|
||||
'follower_name' => $me->name,
|
||||
]),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'following' => true,
|
||||
'count' => $user->followers()->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(User $user): JsonResponse
|
||||
{
|
||||
auth()->user()->following()->detach($user->id);
|
||||
|
||||
return response()->json([
|
||||
'following' => false,
|
||||
'count' => $user->followers()->count(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Game;
|
||||
use App\Models\GameComment;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class GameCommentController extends Controller
|
||||
{
|
||||
public function store(Request $request, Game $game): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'body' => 'required|string|max:1000',
|
||||
'parent_id' => 'nullable|integer|exists:game_comments,id',
|
||||
]);
|
||||
|
||||
if (isset($data['parent_id'])) {
|
||||
$parent = GameComment::findOrFail($data['parent_id']);
|
||||
abort_if($parent->game_id !== $game->id, 422, 'Parent comment does not belong to this game.');
|
||||
}
|
||||
|
||||
$comment = $game->comments()->create([
|
||||
'user_id' => auth()->id(),
|
||||
'body' => $data['body'],
|
||||
'parent_id' => $data['parent_id'] ?? null,
|
||||
]);
|
||||
|
||||
$comment->load('user');
|
||||
|
||||
return response()->json([
|
||||
'id' => $comment->id,
|
||||
'body' => $comment->body,
|
||||
'user' => ['name' => $comment->user->name, 'slug' => $comment->user->slug],
|
||||
'user_id' => $comment->user_id,
|
||||
'created_at' => $comment->created_at->diffForHumans(),
|
||||
'replies_count' => 0,
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function destroy(GameComment $comment): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $comment);
|
||||
$comment->delete();
|
||||
|
||||
return response()->json(['deleted' => true]);
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,9 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Game;
|
||||
use App\Models\GameComment;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Platform;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class GameController extends Controller
|
||||
{
|
||||
@@ -39,19 +37,12 @@ class GameController extends Controller
|
||||
$genres = Genre::orderBy('name')->get();
|
||||
$platforms = Platform::orderBy('name')->get();
|
||||
|
||||
$shouldNoindex = ($request->get('page', 1) > 1) ||
|
||||
(collect(['genre', 'platform', 'year', 'sort', 'q'])->filter(fn($k) => $request->filled($k))->count() >= 2);
|
||||
|
||||
return view('games.index', compact('games', 'genres', 'platforms', 'shouldNoindex'));
|
||||
return view('games.index', compact('games', 'genres', 'platforms'));
|
||||
}
|
||||
|
||||
public function show(Game $game)
|
||||
{
|
||||
$game->load([
|
||||
'genres', 'platforms', 'companies', 'screenshots', 'reviews.user',
|
||||
'collection', 'gameModes', 'themes', 'playerPerspectives', 'videos', 'websites',
|
||||
'similarGames' => fn($q) => $q->published()->with(['genres'])->limit(6),
|
||||
]);
|
||||
$game->load(['genres', 'platforms', 'companies', 'screenshots', 'reviews.user']);
|
||||
|
||||
$userGame = null;
|
||||
$userReview = null;
|
||||
@@ -59,34 +50,10 @@ class GameController extends Controller
|
||||
if (auth()->check()) {
|
||||
$userGame = $game->userGames()->where('user_id', auth()->id())->first();
|
||||
$userReview = $game->reviews()->where('user_id', auth()->id())->first();
|
||||
$userLists = auth()->user()->lists()->get(['id', 'slug', 'title']);
|
||||
$userLists = auth()->user()->lists()->get(['id', 'title']);
|
||||
}
|
||||
|
||||
$comments = $game->comments()->whereNull('parent_id')
|
||||
->with(['user', 'replies.user'])
|
||||
->latest()
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Use IGDB similar_games if available, otherwise fall back to genre-based
|
||||
if ($game->similarGames->isNotEmpty()) {
|
||||
$similarGames = $game->similarGames;
|
||||
} elseif ($game->genres->isNotEmpty()) {
|
||||
$similarGames = Game::published()
|
||||
->whereHas('genres', fn($q) => $q->whereIn('genres.id', $game->genres->pluck('id')))
|
||||
->where('id', '!=', $game->id)
|
||||
->where('rating_count', '>=', 1)
|
||||
->orderByDesc('rating_avg')
|
||||
->limit(6)
|
||||
->get();
|
||||
} else {
|
||||
$similarGames = collect();
|
||||
}
|
||||
|
||||
$response = view('games.show', compact('game', 'userGame', 'userReview', 'userLists', 'comments', 'similarGames'));
|
||||
return auth()->check()
|
||||
? $response
|
||||
: response($response)->header('Cache-Control', 'public, max-age=300');
|
||||
return view('games.show', compact('game', 'userGame', 'userReview', 'userLists'));
|
||||
}
|
||||
|
||||
public function search(Request $request)
|
||||
@@ -95,19 +62,23 @@ class GameController extends Controller
|
||||
$games = collect();
|
||||
|
||||
if (strlen($q) >= 2) {
|
||||
$games = Game::search($q)->get()->filter(fn($g) => $g->status === 'published');
|
||||
$games = Game::published()
|
||||
->where('title', 'like', "%{$q}%")
|
||||
->orderByDesc('rating_count')
|
||||
->limit(20)
|
||||
->get();
|
||||
}
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json($games->take(6)->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),
|
||||
])->values());
|
||||
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'));
|
||||
@@ -115,30 +86,24 @@ class GameController extends Controller
|
||||
|
||||
public function topRated(Request $request)
|
||||
{
|
||||
$page = $request->get('page', 1);
|
||||
$games = Cache::remember("games.top_rated.{$page}", 300, fn() =>
|
||||
Game::published()
|
||||
->where('rating_count', '>=', 1)
|
||||
->orderByDesc('rating_avg')
|
||||
->orderByDesc('rating_count')
|
||||
->paginate(24)
|
||||
->withQueryString()
|
||||
);
|
||||
$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)
|
||||
{
|
||||
$page = $request->get('page', 1);
|
||||
$games = Cache::remember("games.new_releases.{$page}", 300, fn() =>
|
||||
Game::published()
|
||||
->whereNotNull('release_date')
|
||||
->where('release_date', '<=', now())
|
||||
->orderByDesc('release_date')
|
||||
->paginate(24)
|
||||
->withQueryString()
|
||||
);
|
||||
$games = Game::published()
|
||||
->whereNotNull('release_date')
|
||||
->where('release_date', '<=', now())
|
||||
->orderByDesc('release_date')
|
||||
->paginate(24)
|
||||
->withQueryString();
|
||||
|
||||
return view('games.new-releases', compact('games'));
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ class HomeController extends Controller
|
||||
->limit(8)
|
||||
->get();
|
||||
|
||||
return response()->view('home', compact('topRated', 'newReleases'))
|
||||
->header('Cache-Control', 'no-store, private');
|
||||
return view('home', compact('topRated', 'newReleases'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ use Illuminate\Http\Request;
|
||||
|
||||
class LibraryController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
|
||||
@@ -10,18 +10,11 @@ use Illuminate\Support\Str;
|
||||
|
||||
class ListController extends Controller
|
||||
{
|
||||
public function community()
|
||||
public function __construct()
|
||||
{
|
||||
$lists = UserList::where('is_public', true)
|
||||
->with('user')
|
||||
->withCount('listGames')
|
||||
->latest()
|
||||
->paginate(24);
|
||||
|
||||
return view('lists.community', compact('lists'));
|
||||
$this->middleware('auth')->except('show');
|
||||
}
|
||||
|
||||
|
||||
public function index()
|
||||
{
|
||||
$lists = auth()->user()->lists()->withCount('listGames')->latest()->get();
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Notification;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$notifications = auth()->user()
|
||||
->notifications()
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
auth()->user()->notifications()->whereNull('read_at')->update(['read_at' => now()]);
|
||||
|
||||
return view('notifications.index', compact('notifications'));
|
||||
}
|
||||
|
||||
public function markRead(Notification $notification)
|
||||
{
|
||||
abort_if($notification->user_id !== auth()->id(), 403);
|
||||
$notification->update(['read_at' => now()]);
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\ApiKey;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticateApiKey
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$key = $request->bearerToken() ?? $request->get('api_key');
|
||||
|
||||
if (! $key) {
|
||||
return response()->json(['error' => 'Invalid API key'], 401);
|
||||
}
|
||||
|
||||
$apiKey = ApiKey::where('key', $key)->with('user')->first();
|
||||
|
||||
if (! $apiKey) {
|
||||
return response()->json(['error' => 'Invalid API key'], 401);
|
||||
}
|
||||
|
||||
auth()->login($apiKey->user);
|
||||
$apiKey->update(['last_used_at' => now()]);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ApiKey extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'name', 'key'];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public static function generate(User $user, string $name): self
|
||||
{
|
||||
return self::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => $name,
|
||||
'key' => bin2hex(random_bytes(32)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Collection extends Model
|
||||
{
|
||||
protected $fillable = ['igdb_id', 'name', 'slug'];
|
||||
|
||||
public function games(): HasMany
|
||||
{
|
||||
return $this->hasMany(Game::class);
|
||||
}
|
||||
}
|
||||
+5
-67
@@ -3,29 +3,24 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Laravel\Scout\Searchable;
|
||||
use Spatie\Sluggable\HasSlug;
|
||||
use Spatie\Sluggable\SlugOptions;
|
||||
|
||||
class Game extends Model
|
||||
{
|
||||
use HasSlug, Searchable;
|
||||
use HasSlug;
|
||||
|
||||
protected $fillable = [
|
||||
'igdb_id', 'slug', 'title', 'summary', 'storyline', 'cover_url', 'cover_thumb_url',
|
||||
'igdb_id', 'slug', 'title', 'summary', 'cover_url', 'cover_thumb_url',
|
||||
'release_date', 'release_year', 'rating_avg', 'rating_count', 'review_count',
|
||||
'library_count', 'status', 'collection_id', 'age_rating_pegi', 'age_rating_esrb',
|
||||
'aggregated_rating', 'aggregated_rating_count', 'similar_game_igdb_ids',
|
||||
'library_count', 'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'release_date' => 'date',
|
||||
'rating_avg' => 'decimal:2',
|
||||
'similar_game_igdb_ids' => 'array',
|
||||
'release_date' => 'date',
|
||||
'rating_avg' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function getSlugOptions(): SlugOptions
|
||||
@@ -35,11 +30,6 @@ class Game extends Model
|
||||
|
||||
public function getRouteKeyName(): string { return 'slug'; }
|
||||
|
||||
public function collection(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Collection::class);
|
||||
}
|
||||
|
||||
public function genres(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Genre::class, 'game_genre');
|
||||
@@ -70,55 +60,6 @@ class Game extends Model
|
||||
return $this->hasMany(Review::class)->latest();
|
||||
}
|
||||
|
||||
public function comments(): HasMany
|
||||
{
|
||||
return $this->hasMany(GameComment::class);
|
||||
}
|
||||
|
||||
public function gameModes(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(GameMode::class, 'game_game_mode');
|
||||
}
|
||||
|
||||
public function themes(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Theme::class, 'game_theme');
|
||||
}
|
||||
|
||||
public function playerPerspectives(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(PlayerPerspective::class, 'game_player_perspective');
|
||||
}
|
||||
|
||||
public function videos(): HasMany
|
||||
{
|
||||
return $this->hasMany(GameVideo::class);
|
||||
}
|
||||
|
||||
public function websites(): HasMany
|
||||
{
|
||||
return $this->hasMany(GameWebsite::class);
|
||||
}
|
||||
|
||||
public function similarGames(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Game::class,
|
||||
'similar_games',
|
||||
'game_id',
|
||||
'similar_game_id'
|
||||
);
|
||||
}
|
||||
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'title' => $this->title,
|
||||
'summary' => $this->summary,
|
||||
];
|
||||
}
|
||||
|
||||
public function scopePublished($query)
|
||||
{
|
||||
return $query->where('status', 'published');
|
||||
@@ -131,8 +72,5 @@ class Game extends Model
|
||||
'rating_avg' => $stats->avg ? round($stats->avg, 2) : null,
|
||||
'rating_count' => $stats->cnt ?? 0,
|
||||
]);
|
||||
|
||||
Cache::forget('home.top_rated');
|
||||
Cache::forget('games.top_rated.1');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class GameComment extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'game_id', 'parent_id', 'body'];
|
||||
|
||||
public function user(): BelongsTo { return $this->belongsTo(User::class); }
|
||||
public function game(): BelongsTo { return $this->belongsTo(Game::class); }
|
||||
public function replies(): HasMany { return $this->hasMany(GameComment::class, 'parent_id')->with('user')->latest(); }
|
||||
|
||||
public function scopeTopLevel(Builder $query): Builder { return $query->whereNull('parent_id'); }
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class GameMode extends Model
|
||||
{
|
||||
protected $fillable = ['igdb_id', 'name', 'slug'];
|
||||
|
||||
public function games(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Game::class, 'game_game_mode');
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class GameVideo extends Model
|
||||
{
|
||||
protected $fillable = ['game_id', 'igdb_id', 'name', 'video_id'];
|
||||
|
||||
public function game(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Game::class);
|
||||
}
|
||||
|
||||
public function getYoutubeUrlAttribute(): string
|
||||
{
|
||||
return "https://www.youtube.com/watch?v={$this->video_id}";
|
||||
}
|
||||
|
||||
public function getYoutubeEmbedUrlAttribute(): string
|
||||
{
|
||||
return "https://www.youtube.com/embed/{$this->video_id}";
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class GameWebsite extends Model
|
||||
{
|
||||
protected $fillable = ['game_id', 'category', 'url'];
|
||||
|
||||
public const CATEGORY_LABELS = [
|
||||
1 => 'Official',
|
||||
2 => 'Wikia',
|
||||
3 => 'Wikipedia',
|
||||
9 => 'YouTube',
|
||||
13 => 'Steam',
|
||||
16 => 'Epic Games',
|
||||
17 => 'GOG',
|
||||
];
|
||||
|
||||
public function game(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Game::class);
|
||||
}
|
||||
|
||||
public function getLabelAttribute(): string
|
||||
{
|
||||
return self::CATEGORY_LABELS[$this->category] ?? 'Website';
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Notification extends Model
|
||||
{
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = ['id', 'user_id', 'type', 'data', 'read_at', 'created_at'];
|
||||
protected $casts = [
|
||||
'data' => 'array',
|
||||
'read_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class PlayerPerspective extends Model
|
||||
{
|
||||
protected $fillable = ['igdb_id', 'name', 'slug'];
|
||||
|
||||
public function games(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Game::class, 'game_player_perspective');
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\ActivityService;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
@@ -24,10 +23,6 @@ class Review extends Model
|
||||
static::deleted(function (Review $review) {
|
||||
$review->game->recalculateRating();
|
||||
});
|
||||
|
||||
static::created(function (Review $review) {
|
||||
ActivityService::log($review->user, 'reviewed_game', $review->game, ['rating' => $review->rating]);
|
||||
});
|
||||
}
|
||||
|
||||
public function user(): BelongsTo { return $this->belongsTo(User::class); }
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Theme extends Model
|
||||
{
|
||||
protected $fillable = ['igdb_id', 'name', 'slug'];
|
||||
|
||||
public function games(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Game::class, 'game_theme');
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -10,18 +10,16 @@ class User extends Authenticatable
|
||||
{
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
protected $fillable = ['name','slug','email','password','avatar','bio','location','website','is_private','is_admin'];
|
||||
protected $fillable = ['name','username','email','password','avatar','bio','location','website','is_private','is_admin'];
|
||||
protected $hidden = ['password','remember_token'];
|
||||
protected $casts = ['email_verified_at'=>'datetime','password'=>'hashed','is_private'=>'boolean','is_admin'=>'boolean'];
|
||||
|
||||
public function getRouteKeyName(): string { return 'slug'; }
|
||||
public function getRouteKeyName(): string { return 'username'; }
|
||||
|
||||
public function library(): HasMany { return $this->hasMany(UserGame::class); }
|
||||
public function reviews(): HasMany { return $this->hasMany(Review::class); }
|
||||
public function lists(): HasMany { return $this->hasMany(UserList::class); }
|
||||
public function apiKeys(): HasMany { return $this->hasMany(\App\Models\ApiKey::class); }
|
||||
public function activities(): HasMany { return $this->hasMany(Activity::class)->latest(); }
|
||||
public function notifications(): HasMany { return $this->hasMany(Notification::class)->latest('created_at'); }
|
||||
|
||||
public function following(): BelongsToMany
|
||||
{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use App\Services\ActivityService;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
@@ -11,21 +10,6 @@ class UserGame extends Model
|
||||
|
||||
const STATUSES = ['playing','played','want_to_play','on_hold','dropped'];
|
||||
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::created(function (UserGame $ug) {
|
||||
ActivityService::log($ug->user, 'added_to_library', $ug->game, ['status' => $ug->status]);
|
||||
});
|
||||
|
||||
static::updated(function (UserGame $ug) {
|
||||
if ($ug->isDirty('rating') && $ug->rating) {
|
||||
ActivityService::log($ug->user, 'rated_game', $ug->game, ['rating' => $ug->rating]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function user(): BelongsTo { return $this->belongsTo(User::class); }
|
||||
public function game(): BelongsTo { return $this->belongsTo(Game::class); }
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ class UserList extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'title', 'slug', 'description', 'is_public'];
|
||||
|
||||
public function getRouteKeyName(): string { return 'slug'; }
|
||||
|
||||
protected $casts = ['is_public' => 'boolean'];
|
||||
|
||||
protected static function boot(): void
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\ApiKey;
|
||||
use App\Models\User;
|
||||
|
||||
class ApiKeyPolicy
|
||||
{
|
||||
public function delete(User $user, ApiKey $apiKey): bool
|
||||
{
|
||||
return $user->id === $apiKey->user_id;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\GameComment;
|
||||
use App\Models\User;
|
||||
|
||||
class GameCommentPolicy
|
||||
{
|
||||
public function delete(User $user, GameComment $comment): bool
|
||||
{
|
||||
return $user->id === $comment->user_id;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ class ReviewPolicy
|
||||
{
|
||||
public function create(User $user, Game $game): bool
|
||||
{
|
||||
return true;
|
||||
return $user->library()->where('game_id', $game->id)->exists();
|
||||
}
|
||||
|
||||
public function update(User $user, Review $review): bool
|
||||
|
||||
@@ -4,10 +4,7 @@ namespace App\Providers;
|
||||
|
||||
use App\Models\UserGame;
|
||||
use App\Policies\UserGamePolicy;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -28,14 +25,5 @@ class AppServiceProvider extends ServiceProvider
|
||||
Gate::policy(UserGame::class, UserGamePolicy::class);
|
||||
Gate::policy(\App\Models\Review::class, \App\Policies\ReviewPolicy::class);
|
||||
Gate::policy(\App\Models\UserList::class, \App\Policies\ListPolicy::class);
|
||||
Gate::policy(\App\Models\GameComment::class, \App\Policies\GameCommentPolicy::class);
|
||||
Gate::policy(\App\Models\ApiKey::class, \App\Policies\ApiKeyPolicy::class);
|
||||
|
||||
RateLimiter::for('api', function (Request $request) {
|
||||
$key = $request->bearerToken() ?? $request->get('api_key') ?? $request->ip();
|
||||
return Limit::perMinute(60)->by($key)->response(function () {
|
||||
return response()->json(['error' => 'Too many requests. Limit: 60/min.'], 429);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ActivityService
|
||||
{
|
||||
public static function log(User $user, string $type, Model $subject, array $data = []): void
|
||||
{
|
||||
Activity::log($user->id, $type, $subject, $data);
|
||||
}
|
||||
}
|
||||
+24
-231
@@ -2,14 +2,6 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Collection;
|
||||
use App\Models\Company;
|
||||
use App\Models\Game;
|
||||
use App\Models\GameMode;
|
||||
use App\Models\GameVideo;
|
||||
use App\Models\GameWebsite;
|
||||
use App\Models\PlayerPerspective;
|
||||
use App\Models\Theme;
|
||||
use GuzzleHttp\Client;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -21,10 +13,6 @@ class IgdbService
|
||||
protected string $clientSecret;
|
||||
protected string $baseUrl;
|
||||
|
||||
// IGDB age_rating category: 1=ESRB, 2=PEGI
|
||||
private const ESRB_LABELS = [6 => 'RP', 7 => 'EC', 8 => 'E', 9 => 'E10+', 10 => 'T', 11 => 'M', 12 => 'AO'];
|
||||
private const PEGI_LABELS = [1 => '3', 2 => '7', 3 => '12', 4 => '16', 5 => '18'];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->client = new Client(['timeout' => 10]);
|
||||
@@ -72,27 +60,6 @@ class IgdbService
|
||||
}
|
||||
}
|
||||
|
||||
private function extendedFields(): string
|
||||
{
|
||||
return 'id,name,slug,summary,storyline,cover.image_id,first_release_date,
|
||||
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,involved_companies.porting,
|
||||
screenshots.image_id,
|
||||
rating,rating_count,
|
||||
aggregated_rating,aggregated_rating_count,
|
||||
collection.id,collection.name,collection.slug,
|
||||
game_modes.id,game_modes.name,game_modes.slug,
|
||||
themes.id,themes.name,themes.slug,
|
||||
player_perspectives.id,player_perspectives.name,player_perspectives.slug,
|
||||
videos.id,videos.name,videos.video_id,
|
||||
websites.category,websites.url,
|
||||
age_ratings.category,age_ratings.rating,
|
||||
similar_games';
|
||||
}
|
||||
|
||||
/**
|
||||
* Search games by title.
|
||||
*/
|
||||
@@ -114,33 +81,34 @@ class IgdbService
|
||||
public function getGame(int $igdbId): ?array
|
||||
{
|
||||
$results = $this->query('games', "
|
||||
fields {$this->extendedFields()};
|
||||
fields id,name,slug,summary,cover.image_id,first_release_date,
|
||||
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 id = {$igdbId};
|
||||
limit 1;
|
||||
");
|
||||
return $results[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch fresh extended data for a set of IGDB IDs (for backfill).
|
||||
*/
|
||||
public function getGamesByIds(array $igdbIds): array
|
||||
{
|
||||
$ids = implode(',', $igdbIds);
|
||||
return $this->query('games', "
|
||||
fields {$this->extendedFields()};
|
||||
where id = ({$ids});
|
||||
limit " . count($igdbIds) . ";
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get popular games (by rating and rating count).
|
||||
*/
|
||||
public function getPopularGames(int $limit = 20, int $offset = 0): array
|
||||
{
|
||||
return $this->query('games', "
|
||||
fields {$this->extendedFields()};
|
||||
fields id,name,slug,summary,cover.image_id,first_release_date,
|
||||
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;
|
||||
limit {$limit};
|
||||
@@ -148,20 +116,6 @@ class IgdbService
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get games for backfill — broader filter than getPopularGames.
|
||||
*/
|
||||
public function getPopularGamesForBackfill(int $limit = 500, int $offset = 0, int $minRatings = 5): array
|
||||
{
|
||||
return $this->query('games', "
|
||||
fields {$this->extendedFields()};
|
||||
where rating != null & rating_count > {$minRatings} & cover != null;
|
||||
sort rating_count desc;
|
||||
limit {$limit};
|
||||
offset {$offset};
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recently released games.
|
||||
*/
|
||||
@@ -171,44 +125,20 @@ class IgdbService
|
||||
$now = now()->timestamp;
|
||||
|
||||
return $this->query('games', "
|
||||
fields {$this->extendedFields()};
|
||||
fields id,name,slug,summary,cover.image_id,first_release_date,
|
||||
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;
|
||||
limit {$limit};
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get games released in a given year.
|
||||
*/
|
||||
public function getGamesByYear(int $year, int $limit = 20, int $offset = 0): array
|
||||
{
|
||||
$from = mktime(0, 0, 0, 1, 1, $year);
|
||||
$to = mktime(23, 59, 59, 12, 31, $year);
|
||||
|
||||
return $this->query('games', "
|
||||
fields {$this->extendedFields()};
|
||||
where first_release_date >= {$from} & first_release_date <= {$to} & cover != null;
|
||||
sort rating_count desc;
|
||||
limit {$limit};
|
||||
offset {$offset};
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get games for a given platform IGDB ID.
|
||||
*/
|
||||
public function getGamesByPlatform(int $platformIgdbId, int $limit = 20, int $offset = 0): array
|
||||
{
|
||||
return $this->query('games', "
|
||||
fields {$this->extendedFields()};
|
||||
where platforms = [{$platformIgdbId}] & cover != null & rating != null;
|
||||
sort rating_count desc;
|
||||
limit {$limit};
|
||||
offset {$offset};
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get games by genre.
|
||||
*/
|
||||
@@ -225,143 +155,6 @@ class IgdbService
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync all extended IGDB data onto a Game model.
|
||||
*/
|
||||
public function syncExtendedData(Game $game, array $data): void
|
||||
{
|
||||
$updates = [];
|
||||
|
||||
// Collection
|
||||
if (!empty($data['collection'])) {
|
||||
$col = $data['collection'];
|
||||
$collection = Collection::firstOrCreate(
|
||||
['igdb_id' => $col['id']],
|
||||
['name' => $col['name'], 'slug' => $col['slug'] ?? \Str::slug($col['name'])]
|
||||
);
|
||||
$updates['collection_id'] = $collection->id;
|
||||
}
|
||||
|
||||
// Storyline
|
||||
if (!empty($data['storyline'])) {
|
||||
$updates['storyline'] = $data['storyline'];
|
||||
}
|
||||
|
||||
// Aggregated rating (critics)
|
||||
if (isset($data['aggregated_rating'])) {
|
||||
$updates['aggregated_rating'] = round($data['aggregated_rating'], 2);
|
||||
}
|
||||
if (isset($data['aggregated_rating_count'])) {
|
||||
$updates['aggregated_rating_count'] = $data['aggregated_rating_count'];
|
||||
}
|
||||
|
||||
// Age ratings
|
||||
if (!empty($data['age_ratings'])) {
|
||||
foreach ($data['age_ratings'] as $ar) {
|
||||
if (($ar['category'] ?? null) === 1 && isset($ar['rating'])) {
|
||||
$updates['age_rating_esrb'] = self::ESRB_LABELS[$ar['rating']] ?? null;
|
||||
}
|
||||
if (($ar['category'] ?? null) === 2 && isset($ar['rating'])) {
|
||||
$updates['age_rating_pegi'] = self::PEGI_LABELS[$ar['rating']] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Similar games — store IGDB IDs for later resolution
|
||||
if (!empty($data['similar_games'])) {
|
||||
$updates['similar_game_igdb_ids'] = array_values(
|
||||
array_filter(array_map(fn($s) => is_array($s) ? ($s['id'] ?? null) : $s, $data['similar_games']))
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($updates)) {
|
||||
$game->update($updates);
|
||||
}
|
||||
|
||||
// Game modes
|
||||
if (!empty($data['game_modes'])) {
|
||||
$ids = [];
|
||||
foreach ($data['game_modes'] as $gm) {
|
||||
$model = GameMode::firstOrCreate(
|
||||
['igdb_id' => $gm['id']],
|
||||
['name' => $gm['name'], 'slug' => $gm['slug'] ?? \Str::slug($gm['name'])]
|
||||
);
|
||||
$ids[] = $model->id;
|
||||
}
|
||||
$game->gameModes()->sync($ids);
|
||||
}
|
||||
|
||||
// Themes
|
||||
if (!empty($data['themes'])) {
|
||||
$ids = [];
|
||||
foreach ($data['themes'] as $t) {
|
||||
$model = Theme::firstOrCreate(
|
||||
['igdb_id' => $t['id']],
|
||||
['name' => $t['name'], 'slug' => $t['slug'] ?? \Str::slug($t['name'])]
|
||||
);
|
||||
$ids[] = $model->id;
|
||||
}
|
||||
$game->themes()->sync($ids);
|
||||
}
|
||||
|
||||
// Player perspectives
|
||||
if (!empty($data['player_perspectives'])) {
|
||||
$ids = [];
|
||||
foreach ($data['player_perspectives'] as $pp) {
|
||||
$model = PlayerPerspective::firstOrCreate(
|
||||
['igdb_id' => $pp['id']],
|
||||
['name' => $pp['name'], 'slug' => $pp['slug'] ?? \Str::slug($pp['name'])]
|
||||
);
|
||||
$ids[] = $model->id;
|
||||
}
|
||||
$game->playerPerspectives()->sync($ids);
|
||||
}
|
||||
|
||||
// Videos
|
||||
if (isset($data['videos'])) {
|
||||
GameVideo::where('game_id', $game->id)->delete();
|
||||
foreach ($data['videos'] as $v) {
|
||||
if (empty($v['video_id'])) continue;
|
||||
GameVideo::create([
|
||||
'game_id' => $game->id,
|
||||
'igdb_id' => $v['id'] ?? null,
|
||||
'name' => $v['name'] ?? null,
|
||||
'video_id' => $v['video_id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Websites
|
||||
if (isset($data['websites'])) {
|
||||
GameWebsite::where('game_id', $game->id)->delete();
|
||||
foreach ($data['websites'] as $w) {
|
||||
if (empty($w['url'])) continue;
|
||||
GameWebsite::create([
|
||||
'game_id' => $game->id,
|
||||
'category' => $w['category'] ?? null,
|
||||
'url' => $w['url'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Involved companies (update existing game_company pivot with richer data)
|
||||
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];
|
||||
}
|
||||
if (!empty($companyPivot)) {
|
||||
$game->companies()->sync($companyPivot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full cover image URL from an IGDB image_id.
|
||||
*/
|
||||
|
||||
+1
-3
@@ -12,9 +12,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->alias([
|
||||
'api.key' => \App\Http\Middleware\AuthenticateApiKey::class,
|
||||
]);
|
||||
//
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
|
||||
@@ -10,9 +10,7 @@
|
||||
"guzzlehttp/guzzle": "^7.10",
|
||||
"intervention/image": "^4.1",
|
||||
"laravel/framework": "^13.8",
|
||||
"laravel/scout": "^11.2",
|
||||
"laravel/tinker": "^3.0",
|
||||
"spatie/laravel-sitemap": "^7.4",
|
||||
"spatie/laravel-sluggable": "^4.0",
|
||||
"symfony/yaml": "^7.4"
|
||||
},
|
||||
|
||||
Generated
+7
-671
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "26bdf3db91d9eb34ccbe7666ad92c525",
|
||||
"content-hash": "090b1a1ba0d65aaa9d57fd54fdb4370e",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -853,16 +853,16 @@
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/psr7",
|
||||
"version": "2.10.4",
|
||||
"version": "2.10.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/psr7.git",
|
||||
"reference": "d2a1a094e396da8957e797489fddaf860c340cfc"
|
||||
"reference": "7c1472269227dc6f18930bd903d7a88fe6c52130"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/psr7/zipball/d2a1a094e396da8957e797489fddaf860c340cfc",
|
||||
"reference": "d2a1a094e396da8957e797489fddaf860c340cfc",
|
||||
"url": "https://api.github.com/repos/guzzle/psr7/zipball/7c1472269227dc6f18930bd903d7a88fe6c52130",
|
||||
"reference": "7c1472269227dc6f18930bd903d7a88fe6c52130",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -950,7 +950,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/guzzle/psr7/issues",
|
||||
"source": "https://github.com/guzzle/psr7/tree/2.10.4"
|
||||
"source": "https://github.com/guzzle/psr7/tree/2.10.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -966,7 +966,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-29T12:59:07+00:00"
|
||||
"time": "2026-05-27T11:48:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/uri-template",
|
||||
@@ -1481,86 +1481,6 @@
|
||||
},
|
||||
"time": "2026-05-19T00:47:18+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/scout",
|
||||
"version": "v11.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/scout.git",
|
||||
"reference": "5a267813fe0d7bca58b993c3bae592b2c814591e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/scout/zipball/5a267813fe0d7bca58b993c3bae592b2c814591e",
|
||||
"reference": "5a267813fe0d7bca58b993c3bae592b2c814591e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/bus": "^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||
"illuminate/contracts": "^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||
"illuminate/database": "^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||
"illuminate/http": "^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||
"illuminate/pagination": "^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||
"illuminate/queue": "^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||
"illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||
"php": "^8.0",
|
||||
"symfony/console": "^6.0|^7.0|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"algolia/algoliasearch-client-php": "<3.2.0|>=5.0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"algolia/algoliasearch-client-php": "^3.2|^4.0",
|
||||
"meilisearch/meilisearch-php": "^1.0",
|
||||
"mockery/mockery": "^1.0",
|
||||
"orchestra/testbench": "^7.31|^8.36|^9.15|^10.8|^11.0",
|
||||
"php-http/guzzle7-adapter": "^1.0",
|
||||
"phpstan/phpstan": "^1.10",
|
||||
"typesense/typesense-php": "^4.9.3"
|
||||
},
|
||||
"suggest": {
|
||||
"algolia/algoliasearch-client-php": "Required to use the Algolia engine (^3.2).",
|
||||
"meilisearch/meilisearch-php": "Required to use the Meilisearch engine (^1.0).",
|
||||
"typesense/typesense-php": "Required to use the Typesense engine (^4.9)."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Scout\\ScoutServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "11.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Scout\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "Laravel Scout provides a driver based solution to searching your Eloquent models.",
|
||||
"keywords": [
|
||||
"algolia",
|
||||
"laravel",
|
||||
"search"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/scout/issues",
|
||||
"source": "https://github.com/laravel/scout"
|
||||
},
|
||||
"time": "2026-05-13T15:50:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/serializable-closure",
|
||||
"version": "v2.0.13",
|
||||
@@ -2250,73 +2170,6 @@
|
||||
],
|
||||
"time": "2026-03-08T20:05:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "masterminds/html5",
|
||||
"version": "2.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Masterminds/html5-php.git",
|
||||
"reference": "fcf91eb64359852f00d921887b219479b4f21251"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
|
||||
"reference": "fcf91eb64359852f00d921887b219479b4f21251",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"php": ">=5.3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.7-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Masterminds\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Matt Butcher",
|
||||
"email": "technosophos@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Matt Farina",
|
||||
"email": "matt@mattfarina.com"
|
||||
},
|
||||
{
|
||||
"name": "Asmir Mustafic",
|
||||
"email": "goetas@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "An HTML5 parser and serializer.",
|
||||
"homepage": "http://masterminds.github.io/html5-php",
|
||||
"keywords": [
|
||||
"HTML5",
|
||||
"dom",
|
||||
"html",
|
||||
"parser",
|
||||
"querypath",
|
||||
"serializer",
|
||||
"xml"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Masterminds/html5-php/issues",
|
||||
"source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
|
||||
},
|
||||
"time": "2025-07-25T09:04:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "3.10.0",
|
||||
@@ -2683,60 +2536,6 @@
|
||||
},
|
||||
"time": "2026-05-11T20:49:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nicmart/tree",
|
||||
"version": "0.10.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nicmart/Tree.git",
|
||||
"reference": "2ef11e329d26005ef49dbacd0223bcfd2515b6cc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nicmart/Tree/zipball/2ef11e329d26005ef49dbacd0223bcfd2515b6cc",
|
||||
"reference": "2ef11e329d26005ef49dbacd0223bcfd2515b6cc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ergebnis/composer-normalize": "^2.48.2",
|
||||
"ergebnis/license": "^2.7.0",
|
||||
"ergebnis/php-cs-fixer-config": "^6.28.1",
|
||||
"fakerphp/faker": "^1.24.1",
|
||||
"infection/infection": "~0.26.19",
|
||||
"phpunit/phpunit": "^9.6.19",
|
||||
"psalm/plugin-phpunit": "~0.19.0",
|
||||
"vimeo/psalm": "^5.26.1"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Tree\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolò Martini",
|
||||
"email": "nicmartnic@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Andreas Möller",
|
||||
"email": "am@localheinz.com"
|
||||
}
|
||||
],
|
||||
"description": "A basic but flexible php tree data structure and a fluent tree builder implementation.",
|
||||
"support": {
|
||||
"issues": "https://github.com/nicmart/Tree/issues",
|
||||
"source": "https://github.com/nicmart/Tree/tree/0.10.1"
|
||||
},
|
||||
"time": "2025-11-25T08:51:01+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nikic/php-parser",
|
||||
"version": "v5.7.0",
|
||||
@@ -3646,276 +3445,6 @@
|
||||
},
|
||||
"time": "2025-12-14T04:43:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/browsershot",
|
||||
"version": "5.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/browsershot.git",
|
||||
"reference": "dcf7a65fd1d0fc8fd113739b84982377728d0b2f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/browsershot/zipball/dcf7a65fd1d0fc8fd113739b84982377728d0b2f",
|
||||
"reference": "dcf7a65fd1d0fc8fd113739b84982377728d0b2f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-fileinfo": "*",
|
||||
"ext-json": "*",
|
||||
"php": "^8.2",
|
||||
"spatie/temporary-directory": "^2.0",
|
||||
"symfony/process": "^6.0|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest": "^3.0|^4.0",
|
||||
"spatie/image": "^3.6",
|
||||
"spatie/pdf-to-text": "^1.52",
|
||||
"spatie/phpunit-snapshot-assertions": "^5.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\Browsershot\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "freek@spatie.be",
|
||||
"homepage": "https://github.com/freekmurze",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Convert a webpage to an image or pdf using headless Chrome",
|
||||
"homepage": "https://github.com/spatie/browsershot",
|
||||
"keywords": [
|
||||
"chrome",
|
||||
"convert",
|
||||
"headless",
|
||||
"image",
|
||||
"pdf",
|
||||
"puppeteer",
|
||||
"screenshot",
|
||||
"webpage"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/spatie/browsershot/tree/5.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-26T13:13:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/crawler",
|
||||
"version": "8.5.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/crawler.git",
|
||||
"reference": "18198a2198adff1637c0028cb60d2c9559721556"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/crawler/zipball/18198a2198adff1637c0028cb60d2c9559721556",
|
||||
"reference": "18198a2198adff1637c0028cb60d2c9559721556",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"guzzlehttp/guzzle": "^7.3",
|
||||
"guzzlehttp/psr7": "^2.0",
|
||||
"illuminate/collections": "^10.0|^11.0|^12.0|^13.0",
|
||||
"nicmart/tree": "^0.10",
|
||||
"php": "^8.2",
|
||||
"spatie/browsershot": "^5.0.5",
|
||||
"spatie/robots-txt": "^2.0",
|
||||
"symfony/dom-crawler": "^6.0|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest": "^2.0|^3.0|^4.0",
|
||||
"spatie/ray": "^1.37"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\Crawler\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "freek@spatie.be"
|
||||
}
|
||||
],
|
||||
"description": "Crawl all internal links found on a website",
|
||||
"homepage": "https://github.com/spatie/crawler",
|
||||
"keywords": [
|
||||
"crawler",
|
||||
"link",
|
||||
"spatie",
|
||||
"website"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/crawler/issues",
|
||||
"source": "https://github.com/spatie/crawler/tree/8.5.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://spatie.be/open-source/support-us",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-21T22:52:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/laravel-package-tools",
|
||||
"version": "1.93.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/laravel-package-tools.git",
|
||||
"reference": "d5552849801f2642aea710557463234b59ef65eb"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb",
|
||||
"reference": "d5552849801f2642aea710557463234b59ef65eb",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.5",
|
||||
"orchestra/testbench": "^8.0|^9.2|^10.0|^11.0",
|
||||
"pestphp/pest": "^2.1|^3.1|^4.0",
|
||||
"phpunit/php-code-coverage": "^10.0|^11.0|^12.0",
|
||||
"phpunit/phpunit": "^10.5|^11.5|^12.5",
|
||||
"spatie/pest-plugin-test-time": "^2.2|^3.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\LaravelPackageTools\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "freek@spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Tools for creating Laravel packages",
|
||||
"homepage": "https://github.com/spatie/laravel-package-tools",
|
||||
"keywords": [
|
||||
"laravel-package-tools",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/laravel-package-tools/issues",
|
||||
"source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-19T14:06:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/laravel-sitemap",
|
||||
"version": "7.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/laravel-sitemap.git",
|
||||
"reference": "52397c6f4219a8a0a163ddb8a8d5bc5f9bba0df4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/laravel-sitemap/zipball/52397c6f4219a8a0a163ddb8a8d5bc5f9bba0df4",
|
||||
"reference": "52397c6f4219a8a0a163ddb8a8d5bc5f9bba0df4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"guzzlehttp/guzzle": "^7.8",
|
||||
"illuminate/support": "^11.0|^12.0||^13.0",
|
||||
"nesbot/carbon": "^2.71|^3.0",
|
||||
"php": "^8.2||^8.3||^8.4||^8.5",
|
||||
"spatie/crawler": "^8.0.1",
|
||||
"spatie/laravel-package-tools": "^1.16.1",
|
||||
"symfony/dom-crawler": "^6.3.4|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.6.6",
|
||||
"orchestra/testbench": "^9.0|^10.0||^11.0",
|
||||
"pestphp/pest": "^3.7.4|^4.0",
|
||||
"spatie/pest-plugin-snapshots": "^2.1",
|
||||
"spatie/phpunit-snapshot-assertions": "^5.1.2",
|
||||
"spatie/temporary-directory": "^2.2"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Spatie\\Sitemap\\SitemapServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\Sitemap\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "freek@spatie.be",
|
||||
"homepage": "https://spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Create and generate sitemaps with ease",
|
||||
"homepage": "https://github.com/spatie/laravel-sitemap",
|
||||
"keywords": [
|
||||
"laravel-sitemap",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/spatie/laravel-sitemap/tree/7.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://spatie.be/open-source/support-us",
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-21T23:00:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/laravel-sluggable",
|
||||
"version": "4.0.2",
|
||||
@@ -3994,127 +3523,6 @@
|
||||
],
|
||||
"time": "2026-04-30T17:28:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/robots-txt",
|
||||
"version": "2.5.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/robots-txt.git",
|
||||
"reference": "a8dd35d0a94e863f52509a366a634978e9c1db03"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/robots-txt/zipball/a8dd35d0a94e863f52509a366a634978e9c1db03",
|
||||
"reference": "a8dd35d0a94e863f52509a366a634978e9c1db03",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.5.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\Robots\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Brent Roose",
|
||||
"email": "brent@spatie.be",
|
||||
"homepage": "https://spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Determine if a page may be crawled from robots.txt and robots meta tags",
|
||||
"homepage": "https://github.com/spatie/robots-txt",
|
||||
"keywords": [
|
||||
"robots-txt",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/robots-txt/issues",
|
||||
"source": "https://github.com/spatie/robots-txt/tree/2.5.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://spatie.be/open-source/support-us",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-25T07:59:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/temporary-directory",
|
||||
"version": "2.3.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/temporary-directory.git",
|
||||
"reference": "662e481d6ec07ef29fd05010433428851a42cd07"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/temporary-directory/zipball/662e481d6ec07ef29fd05010433428851a42cd07",
|
||||
"reference": "662e481d6ec07ef29fd05010433428851a42cd07",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.5"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\TemporaryDirectory\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Alex Vanderbist",
|
||||
"email": "alex@spatie.be",
|
||||
"homepage": "https://spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Easily create, use and destroy temporary directories",
|
||||
"homepage": "https://github.com/spatie/temporary-directory",
|
||||
"keywords": [
|
||||
"php",
|
||||
"spatie",
|
||||
"temporary-directory"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/temporary-directory/issues",
|
||||
"source": "https://github.com/spatie/temporary-directory/tree/2.3.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://spatie.be/open-source/support-us",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-01-12T07:42:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/clock",
|
||||
"version": "v7.4.8",
|
||||
@@ -4431,78 +3839,6 @@
|
||||
],
|
||||
"time": "2026-04-13T15:52:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/dom-crawler",
|
||||
"version": "v7.4.12",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/dom-crawler.git",
|
||||
"reference": "b59b59122690976550fd142c23fab62c84738db6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/dom-crawler/zipball/b59b59122690976550fd142c23fab62c84738db6",
|
||||
"reference": "b59b59122690976550fd142c23fab62c84738db6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"masterminds/html5": "^2.6",
|
||||
"php": ">=8.2",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/polyfill-ctype": "~1.8",
|
||||
"symfony/polyfill-mbstring": "~1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/css-selector": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\DomCrawler\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Eases DOM navigation for HTML and XML documents",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/dom-crawler/tree/v7.4.12"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-20T07:20:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/error-handler",
|
||||
"version": "v7.4.8",
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Search Engine
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default search connection that gets used while
|
||||
| using Laravel Scout. This connection is used when syncing all models
|
||||
| to the search service. You should adjust this based on your needs.
|
||||
|
|
||||
| Supported: "algolia", "meilisearch", "typesense",
|
||||
| "database", "collection", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => env('SCOUT_DRIVER', 'collection'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Index Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify a prefix that will be applied to all search index
|
||||
| names used by Scout. This prefix may be useful if you have multiple
|
||||
| "tenants" or applications sharing the same search infrastructure.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('SCOUT_PREFIX', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Data Syncing
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to control if the operations that sync your data
|
||||
| with your search engines are queued. When this is set to "true" then
|
||||
| all automatic data syncing will get queued for better performance.
|
||||
|
|
||||
*/
|
||||
|
||||
'queue' => env('SCOUT_QUEUE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Transactions
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This configuration option determines if your data will only be synced
|
||||
| with your search indexes after every open database transaction has
|
||||
| been committed, thus preventing any discarded data from syncing.
|
||||
|
|
||||
*/
|
||||
|
||||
'after_commit' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Chunk Sizes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These options allow you to control the maximum chunk size when you are
|
||||
| mass importing data into the search engine. This allows you to fine
|
||||
| tune each of these chunk sizes based on the power of the servers.
|
||||
|
|
||||
*/
|
||||
|
||||
'chunk' => [
|
||||
'searchable' => 500,
|
||||
'unsearchable' => 500,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Soft Deletes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows to control whether to keep soft deleted records in
|
||||
| the search indexes. Maintaining soft deleted records can be useful
|
||||
| if your application still needs to search for the records later.
|
||||
|
|
||||
*/
|
||||
|
||||
'soft_delete' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Identify User
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to control whether to notify the search engine
|
||||
| of the user performing the search. This is sometimes useful if the
|
||||
| engine supports any analytics based on this application's users.
|
||||
|
|
||||
| Supported engines: "algolia"
|
||||
|
|
||||
*/
|
||||
|
||||
'identify' => env('SCOUT_IDENTIFY', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Algolia Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure your Algolia settings. Algolia is a cloud hosted
|
||||
| search engine which works great with Scout out of the box. Just plug
|
||||
| in your application ID and admin API key to get started searching.
|
||||
|
|
||||
*/
|
||||
|
||||
'algolia' => [
|
||||
'id' => env('ALGOLIA_APP_ID', ''),
|
||||
'secret' => env('ALGOLIA_SECRET', ''),
|
||||
'index-settings' => [
|
||||
// 'users' => [
|
||||
// 'searchableAttributes' => ['id', 'name', 'email'],
|
||||
// 'attributesForFaceting'=> ['filterOnly(email)'],
|
||||
// ],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Meilisearch Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure your Meilisearch settings. Meilisearch is an open
|
||||
| source search engine with minimal configuration. Below, you can state
|
||||
| the host and key information for your own Meilisearch installation.
|
||||
|
|
||||
| See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options
|
||||
|
|
||||
*/
|
||||
|
||||
'meilisearch' => [
|
||||
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
|
||||
'key' => env('MEILISEARCH_KEY'),
|
||||
'index-settings' => [
|
||||
// 'users' => [
|
||||
// 'filterableAttributes'=> ['id', 'name', 'email'],
|
||||
// ],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Typesense Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure your Typesense settings. Typesense is an open
|
||||
| source search engine using minimal configuration. Below, you will
|
||||
| state the host, key, and schema configuration for the instance.
|
||||
|
|
||||
*/
|
||||
|
||||
'typesense' => [
|
||||
'client-settings' => [
|
||||
'api_key' => env('TYPESENSE_API_KEY', 'xyz'),
|
||||
'nodes' => [
|
||||
[
|
||||
'host' => env('TYPESENSE_HOST', 'localhost'),
|
||||
'port' => env('TYPESENSE_PORT', '8108'),
|
||||
'path' => env('TYPESENSE_PATH', ''),
|
||||
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
|
||||
],
|
||||
],
|
||||
'nearest_node' => [
|
||||
'host' => env('TYPESENSE_HOST', 'localhost'),
|
||||
'port' => env('TYPESENSE_PORT', '8108'),
|
||||
'path' => env('TYPESENSE_PATH', ''),
|
||||
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
|
||||
],
|
||||
'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2),
|
||||
'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30),
|
||||
'num_retries' => env('TYPESENSE_NUM_RETRIES', 3),
|
||||
'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1),
|
||||
],
|
||||
// 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000),
|
||||
'model-settings' => [
|
||||
// User::class => [
|
||||
// 'collection-schema' => [
|
||||
// 'fields' => [
|
||||
// [
|
||||
// 'name' => 'id',
|
||||
// 'type' => 'string',
|
||||
// ],
|
||||
// [
|
||||
// 'name' => 'name',
|
||||
// 'type' => 'string',
|
||||
// ],
|
||||
// [
|
||||
// 'name' => 'created_at',
|
||||
// 'type' => 'int64',
|
||||
// ],
|
||||
// ],
|
||||
// 'default_sorting_field' => 'created_at',
|
||||
// ],
|
||||
// 'search-parameters' => [
|
||||
// 'query_by' => 'name'
|
||||
// ],
|
||||
// ],
|
||||
],
|
||||
'import_action' => env('TYPESENSE_IMPORT_ACTION', 'upsert'),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('game_comments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('game_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('parent_id')->nullable()->constrained('game_comments')->cascadeOnDelete();
|
||||
$table->text('body');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('game_comments');
|
||||
}
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notifications', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('type', 100);
|
||||
$table->json('data');
|
||||
$table->timestamp('read_at')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notifications');
|
||||
}
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->renameColumn('username', 'slug');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->renameColumn('slug', 'username');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('api_keys', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name', 50);
|
||||
$table->string('key', 64)->unique();
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('api_keys');
|
||||
}
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::create('collections', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedInteger('igdb_id')->unique();
|
||||
$table->string('name');
|
||||
$table->string('slug')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
public function down(): void { Schema::dropIfExists('collections'); }
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::create('game_modes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedInteger('igdb_id')->unique();
|
||||
$table->string('name');
|
||||
$table->string('slug')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
public function down(): void { Schema::dropIfExists('game_modes'); }
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::create('themes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedInteger('igdb_id')->unique();
|
||||
$table->string('name');
|
||||
$table->string('slug')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
public function down(): void { Schema::dropIfExists('themes'); }
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::create('player_perspectives', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedInteger('igdb_id')->unique();
|
||||
$table->string('name');
|
||||
$table->string('slug')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
public function down(): void { Schema::dropIfExists('player_perspectives'); }
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::table('games', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('collection_id')->nullable()->after('igdb_id');
|
||||
$table->string('age_rating_pegi', 10)->nullable()->after('summary');
|
||||
$table->string('age_rating_esrb', 10)->nullable()->after('age_rating_pegi');
|
||||
$table->decimal('aggregated_rating', 5, 2)->nullable()->after('age_rating_esrb');
|
||||
$table->unsignedInteger('aggregated_rating_count')->nullable()->after('aggregated_rating');
|
||||
$table->text('storyline')->nullable()->after('summary');
|
||||
$table->json('similar_game_igdb_ids')->nullable()->after('storyline');
|
||||
|
||||
$table->foreign('collection_id')->references('id')->on('collections')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
public function down(): void {
|
||||
Schema::table('games', function (Blueprint $table) {
|
||||
$table->dropForeign(['collection_id']);
|
||||
$table->dropColumn(['collection_id', 'age_rating_pegi', 'age_rating_esrb', 'aggregated_rating', 'aggregated_rating_count', 'storyline', 'similar_game_igdb_ids']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::create('game_videos', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('game_id')->constrained()->cascadeOnDelete();
|
||||
$table->unsignedInteger('igdb_id')->nullable();
|
||||
$table->string('name')->nullable();
|
||||
$table->string('video_id');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
public function down(): void { Schema::dropIfExists('game_videos'); }
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::create('game_websites', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('game_id')->constrained()->cascadeOnDelete();
|
||||
$table->unsignedTinyInteger('category')->nullable(true);
|
||||
$table->string('url');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
public function down(): void { Schema::dropIfExists('game_websites'); }
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::create('similar_games', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('game_id');
|
||||
$table->unsignedBigInteger('similar_game_id');
|
||||
$table->primary(['game_id', 'similar_game_id']);
|
||||
$table->foreign('game_id')->references('id')->on('games')->cascadeOnDelete();
|
||||
$table->foreign('similar_game_id')->references('id')->on('games')->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
public function down(): void { Schema::dropIfExists('similar_games'); }
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::create('game_game_mode', function (Blueprint $table) {
|
||||
$table->foreignId('game_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('game_mode_id')->constrained('game_modes')->cascadeOnDelete();
|
||||
$table->primary(['game_id', 'game_mode_id']);
|
||||
});
|
||||
|
||||
Schema::create('game_theme', function (Blueprint $table) {
|
||||
$table->foreignId('game_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('theme_id')->constrained('themes')->cascadeOnDelete();
|
||||
$table->primary(['game_id', 'theme_id']);
|
||||
});
|
||||
|
||||
Schema::create('game_player_perspective', function (Blueprint $table) {
|
||||
$table->foreignId('game_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('player_perspective_id')->constrained('player_perspectives')->cascadeOnDelete();
|
||||
$table->primary(['game_id', 'player_perspective_id']);
|
||||
});
|
||||
}
|
||||
public function down(): void {
|
||||
Schema::dropIfExists('game_player_perspective');
|
||||
Schema::dropIfExists('game_theme');
|
||||
Schema::dropIfExists('game_game_mode');
|
||||
}
|
||||
};
|
||||
@@ -1,429 +0,0 @@
|
||||
# GameLog Feature Batch Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add follow system, activity feed, notifications, sitemap, Scout search, and caching to the GameLog Laravel 11 app.
|
||||
|
||||
**Architecture:** All features hook into existing User/Game/UserGame/Review models. Migrations for follows/activities already ran. The nav (layouts/app.blade.php) handles all global UI additions. Scout uses the `database` driver for dev (SQLite).
|
||||
|
||||
**Tech Stack:** Laravel 13 + Tailwind + Alpine.js, SQLite dev, spatie/laravel-sitemap, laravel/scout
|
||||
|
||||
---
|
||||
|
||||
### Task 1: FollowController + Routes
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Http/Controllers/FollowController.php`
|
||||
- Modify: `routes/web.php`
|
||||
|
||||
- [ ] Create FollowController with store/destroy returning JSON
|
||||
|
||||
```php
|
||||
<?php
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class FollowController extends Controller
|
||||
{
|
||||
public function store(User $user): JsonResponse
|
||||
{
|
||||
$me = auth()->user();
|
||||
if ($me->id === $user->id) {
|
||||
return response()->json(['error' => 'Cannot follow yourself'], 422);
|
||||
}
|
||||
if (!$me->following()->where('following_id', $user->id)->exists()) {
|
||||
$me->following()->attach($user->id);
|
||||
$user->notifications()->create([
|
||||
'id' => \Illuminate\Support\Str::uuid(),
|
||||
'type' => 'new_follower',
|
||||
'data' => json_encode([
|
||||
'follower_id' => $me->id,
|
||||
'follower_username' => $me->username,
|
||||
'follower_name' => $me->name,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
return response()->json([
|
||||
'following' => true,
|
||||
'count' => $user->followers()->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(User $user): JsonResponse
|
||||
{
|
||||
auth()->user()->following()->detach($user->id);
|
||||
return response()->json([
|
||||
'following' => false,
|
||||
'count' => $user->followers()->count(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Add routes to web.php (auth group)
|
||||
|
||||
```php
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::post('/users/{user:username}/follow', [\App\Http\Controllers\FollowController::class, 'store'])->name('users.follow');
|
||||
Route::delete('/users/{user:username}/follow', [\App\Http\Controllers\FollowController::class, 'destroy'])->name('users.unfollow');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] Commit: `git commit -m "feat: add FollowController and routes"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Notifications Migration + Model
|
||||
|
||||
**Files:**
|
||||
- Create: `database/migrations/2026_05_30_000001_create_notifications_table.php`
|
||||
- Create: `app/Models/Notification.php`
|
||||
|
||||
- [ ] Create notifications migration
|
||||
|
||||
```php
|
||||
<?php
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::create('notifications', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('type', 100);
|
||||
$table->json('data');
|
||||
$table->timestamp('read_at')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
}
|
||||
public function down(): void { Schema::dropIfExists('notifications'); }
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] Create Notification model
|
||||
|
||||
```php
|
||||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Notification extends Model
|
||||
{
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
public $timestamps = false;
|
||||
protected $fillable = ['id', 'user_id', 'type', 'data', 'read_at'];
|
||||
protected $casts = ['data' => 'array', 'read_at' => 'datetime', 'created_at' => 'datetime'];
|
||||
|
||||
public function user(): BelongsTo { return $this->belongsTo(User::class); }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Run migration: `php artisan migrate`
|
||||
|
||||
- [ ] Commit: `git commit -m "feat: add notifications table and model"`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Follow Button on users/show.blade.php
|
||||
|
||||
**Files:**
|
||||
- Modify: `resources/views/users/show.blade.php`
|
||||
|
||||
- [ ] Add Alpine.js follow button block after `<div class="flex gap-2">` (replaces the lone "View Library" button container)
|
||||
|
||||
Replace the `<div class="flex gap-2">` block with:
|
||||
|
||||
```html
|
||||
<div class="flex items-center gap-4">
|
||||
{{-- Follower / Following counts --}}
|
||||
<div class="flex gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-lg font-bold text-white">{{ $user->followers()->count() }}</div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wide">Followers</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-bold text-white">{{ $user->following()->count() }}</div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wide">Following</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
@auth
|
||||
@if(auth()->id() !== $user->id)
|
||||
<div x-data="{
|
||||
following: {{ auth()->user()->isFollowing($user) ? 'true' : 'false' }},
|
||||
count: {{ $user->followers()->count() }},
|
||||
async toggle() {
|
||||
const method = this.following ? 'DELETE' : 'POST';
|
||||
const res = await fetch('{{ route('users.follow', $user->username) }}', {
|
||||
method,
|
||||
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content, 'Accept': 'application/json' }
|
||||
});
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
this.following = d.following;
|
||||
this.count = d.count;
|
||||
}
|
||||
}
|
||||
}">
|
||||
<button @click="toggle()"
|
||||
:class="following ? 'bg-white/10 text-slate-300 hover:bg-red-900/30 hover:text-red-400 border border-white/10' : 'bg-purple-600 hover:bg-purple-700 text-white'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors">
|
||||
<span x-text="following ? 'Following' : 'Follow'"></span>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
@endauth
|
||||
<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>
|
||||
```
|
||||
|
||||
- [ ] Commit: `git commit -m "feat: add follow button and follower counts to user profile"`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: ActivityService + Hook into Models
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Services/ActivityService.php`
|
||||
- Modify: `app/Models/UserGame.php`
|
||||
- Modify: `app/Models/Review.php`
|
||||
|
||||
- [ ] Create ActivityService (thin wrapper around Activity::log)
|
||||
|
||||
```php
|
||||
<?php
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ActivityService
|
||||
{
|
||||
public static function log(User $user, string $type, Model $subject, array $data = []): void
|
||||
{
|
||||
Activity::log($user->id, $type, $subject, $data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Add boot() hooks to UserGame model
|
||||
|
||||
```php
|
||||
use App\Services\ActivityService;
|
||||
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::created(function (UserGame $ug) {
|
||||
ActivityService::log($ug->user, 'added_to_library', $ug->game, ['status' => $ug->status]);
|
||||
});
|
||||
|
||||
static::updated(function (UserGame $ug) {
|
||||
if ($ug->isDirty('rating') && $ug->rating) {
|
||||
ActivityService::log($ug->user, 'rated_game', $ug->game, ['rating' => $ug->rating]);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Add to Review model boot() after the existing saved/deleted callbacks
|
||||
|
||||
```php
|
||||
use App\Services\ActivityService;
|
||||
|
||||
// Inside the static::created callback (add this inside the boot method after existing code):
|
||||
static::created(function (Review $review) {
|
||||
ActivityService::log($review->user, 'reviewed_game', $review->game, ['rating' => $review->rating]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] Commit: `git commit -m "feat: add ActivityService and log hooks on UserGame/Review"`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: ActivityController + Routes + View
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Http/Controllers/ActivityController.php`
|
||||
- Modify: `routes/web.php`
|
||||
- Create: `resources/views/activity/index.blade.php`
|
||||
|
||||
- [ ] Create ActivityController
|
||||
|
||||
```php
|
||||
<?php
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Activity;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ActivityController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$user = auth()->user();
|
||||
$followingIds = $user->following()->pluck('users.id')->push($user->id);
|
||||
|
||||
$activities = Activity::with(['user', 'subject'])
|
||||
->whereIn('user_id', $followingIds)
|
||||
->latest('created_at')
|
||||
->paginate(40);
|
||||
|
||||
return view('activity.index', compact('activities'));
|
||||
}
|
||||
|
||||
public function global()
|
||||
{
|
||||
$activities = Activity::with(['user', 'subject'])
|
||||
->latest('created_at')
|
||||
->paginate(40);
|
||||
|
||||
return view('activity.index', compact('activities'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Add activity routes to web.php
|
||||
|
||||
```php
|
||||
Route::get('/activity', [\App\Http\Controllers\ActivityController::class, 'index'])->name('activity.index')->middleware('auth');
|
||||
Route::get('/activity/global', [\App\Http\Controllers\ActivityController::class, 'global'])->name('activity.global');
|
||||
```
|
||||
|
||||
- [ ] Create activity/index.blade.php view (see full content below)
|
||||
|
||||
- [ ] Commit: `git commit -m "feat: add activity feed controller, routes, and view"`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: NotificationController + Routes + View
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Http/Controllers/NotificationController.php`
|
||||
- Modify: `routes/web.php`
|
||||
- Create: `resources/views/notifications/index.blade.php`
|
||||
|
||||
- [ ] Create NotificationController
|
||||
|
||||
```php
|
||||
<?php
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Notification;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$notifications = auth()->user()
|
||||
->notifications()
|
||||
->latest('created_at')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
auth()->user()->notifications()->whereNull('read_at')->update(['read_at' => now()]);
|
||||
|
||||
return view('notifications.index', compact('notifications'));
|
||||
}
|
||||
|
||||
public function markRead(Notification $notification)
|
||||
{
|
||||
abort_if($notification->user_id !== auth()->id(), 403);
|
||||
$notification->update(['read_at' => now()]);
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Add notification routes to web.php (auth group)
|
||||
|
||||
```php
|
||||
Route::get('/notifications', [\App\Http\Controllers\NotificationController::class, 'index'])->name('notifications.index')->middleware('auth');
|
||||
Route::post('/notifications/{notification}/read', [\App\Http\Controllers\NotificationController::class, 'markRead'])->name('notifications.read')->middleware('auth');
|
||||
```
|
||||
|
||||
- [ ] Add to User model: `public function notifications() { return $this->hasMany(Notification::class)->latest('created_at'); }`
|
||||
|
||||
- [ ] Create notifications/index.blade.php view
|
||||
|
||||
- [ ] Add bell icon to nav in layouts/app.blade.php
|
||||
|
||||
- [ ] Commit: `git commit -m "feat: add notifications controller, routes, view, and nav bell"`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Sitemap
|
||||
|
||||
**Files:**
|
||||
- Run composer require
|
||||
- Create: `app/Console/Commands/GenerateSitemapCommand.php`
|
||||
- Modify: `routes/console.php` (Laravel 11 schedules here)
|
||||
- Modify: `routes/web.php`
|
||||
- Modify: `public/robots.txt`
|
||||
|
||||
- [ ] Install spatie/laravel-sitemap: `composer require spatie/laravel-sitemap`
|
||||
|
||||
- [ ] Create GenerateSitemapCommand
|
||||
|
||||
- [ ] Add weekly schedule in routes/console.php
|
||||
|
||||
- [ ] Add sitemap route in web.php
|
||||
|
||||
- [ ] Update robots.txt
|
||||
|
||||
- [ ] Commit: `git commit -m "feat: add sitemap generation command and route"`
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Scout Full-Text Search
|
||||
|
||||
**Files:**
|
||||
- Run composer require + vendor:publish
|
||||
- Modify: `app/Models/Game.php`
|
||||
- Modify: `app/Http/Controllers/GameController.php`
|
||||
- Modify: `.env`
|
||||
|
||||
- [ ] Install Scout: `composer require laravel/scout && php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"`
|
||||
|
||||
- [ ] Add SCOUT_DRIVER=database to .env
|
||||
|
||||
- [ ] Add Searchable trait and toSearchableArray to Game model
|
||||
|
||||
- [ ] Update GameController@search to use Scout
|
||||
|
||||
- [ ] Commit: `git commit -m "feat: add Laravel Scout search to Game model"`
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Performance Caching
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/Http/Controllers/HomeController.php`
|
||||
- Modify: `app/Http/Controllers/GameController.php`
|
||||
- Modify: `app/Models/Game.php`
|
||||
|
||||
- [ ] Wrap HomeController queries in Cache::remember
|
||||
|
||||
- [ ] Wrap GameController topRated/newReleases in Cache::remember with page key
|
||||
|
||||
- [ ] Flush caches in Game::recalculateRating()
|
||||
|
||||
- [ ] Add cache header to GameController@show response
|
||||
|
||||
- [ ] Commit: `git commit -m "feat: add file cache to home/top-rated/new-releases queries"`
|
||||
|
||||
---
|
||||
@@ -1,998 +0,0 @@
|
||||
# Public API v1 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a versioned public REST API (v1) with API key authentication, rate limiting, and a documentation page.
|
||||
|
||||
**Architecture:** API key auth via `Authorization: Bearer` header or `?api_key=` query param; all endpoints under `/api/v1/` prefix with 60 req/min throttle. API key management lives in the web app under `/settings/api-keys`. JSON controllers live in `App\Http\Controllers\Api\V1\`.
|
||||
|
||||
**Tech Stack:** Laravel 11, PHP 8.3, Blade + Tailwind + Alpine.js, no additional packages required.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
**Create:**
|
||||
- `database/migrations/2026_05_31_000001_create_api_keys_table.php`
|
||||
- `app/Models/ApiKey.php`
|
||||
- `app/Http/Middleware/AuthenticateApiKey.php`
|
||||
- `app/Http/Controllers/ApiKeyController.php`
|
||||
- `app/Http/Controllers/Api/V1/GameApiController.php`
|
||||
- `app/Http/Controllers/Api/V1/MetaApiController.php`
|
||||
- `app/Http/Controllers/Api/V1/UserApiController.php`
|
||||
- `resources/views/settings/api-keys.blade.php`
|
||||
- `resources/views/api/docs.blade.php`
|
||||
- `routes/api.php`
|
||||
|
||||
**Modify:**
|
||||
- `bootstrap/app.php` — register `api.key` middleware alias, load `routes/api.php`, add JSON 404
|
||||
- `app/Providers/AppServiceProvider.php` — add rate limiter
|
||||
- `routes/web.php` — add API key management routes
|
||||
- `resources/views/layouts/app.blade.php` — add "API" footer/nav link
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Database migration for `api_keys`
|
||||
|
||||
**Files:**
|
||||
- Create: `database/migrations/2026_05_31_000001_create_api_keys_table.php`
|
||||
|
||||
- [ ] **Step 1: Create migration file**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('api_keys', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name', 50);
|
||||
$table->string('key', 64)->unique();
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('api_keys');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run migration**
|
||||
|
||||
```bash
|
||||
php artisan migrate
|
||||
```
|
||||
Expected: `Migrating: 2026_05_31_000001_create_api_keys_table` then `Migrated`.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `ApiKey` model
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Models/ApiKey.php`
|
||||
|
||||
- [ ] **Step 1: Create model**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ApiKey extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'name', 'key'];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public static function generate(User $user, string $name): self
|
||||
{
|
||||
return self::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => $name,
|
||||
'key' => bin2hex(random_bytes(32)), // 64 hex chars
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `AuthenticateApiKey` middleware
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Http/Middleware/AuthenticateApiKey.php`
|
||||
|
||||
- [ ] **Step 1: Create middleware**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\ApiKey;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticateApiKey
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$key = $request->bearerToken() ?? $request->get('api_key');
|
||||
|
||||
if (! $key) {
|
||||
return response()->json(['error' => 'Invalid API key'], 401);
|
||||
}
|
||||
|
||||
$apiKey = ApiKey::where('key', $key)->with('user')->first();
|
||||
|
||||
if (! $apiKey) {
|
||||
return response()->json(['error' => 'Invalid API key'], 401);
|
||||
}
|
||||
|
||||
auth()->login($apiKey->user);
|
||||
$apiKey->update(['last_used_at' => now()]);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Register middleware alias and load API routes in `bootstrap/app.php`
|
||||
|
||||
**Files:**
|
||||
- Modify: `bootstrap/app.php`
|
||||
|
||||
- [ ] **Step 1: Update `bootstrap/app.php`**
|
||||
|
||||
Replace the entire file with:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->alias([
|
||||
'api.key' => \App\Http\Middleware\AuthenticateApiKey::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
fn (Request $request) => $request->is('api/*'),
|
||||
);
|
||||
})->create();
|
||||
```
|
||||
|
||||
Note: Laravel 11's `withRouting(api:)` automatically prefixes `/api` and applies the `api` middleware group. Since we want `/api/v1/` paths, we'll use explicit Route::prefix in `routes/api.php` WITHOUT the automatic `/api` prefix — so we define routes in `web.php` or use a custom file. Actually, Laravel 11's `api:` param in `withRouting` wraps routes in `Route::middleware('api')->prefix('api')`. That would make our paths `/api/api/v1/...`. Instead, add the API routes file directly in `web.php` via `require`. See Task 8 for the approach.
|
||||
|
||||
**Revised Step 1: Update `bootstrap/app.php` (without `api:` param)**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->alias([
|
||||
'api.key' => \App\Http\Middleware\AuthenticateApiKey::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
fn (Request $request) => $request->is('api/*'),
|
||||
);
|
||||
})->create();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Rate limiter in `AppServiceProvider`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/Providers/AppServiceProvider.php`
|
||||
|
||||
- [ ] **Step 1: Add rate limiter to `boot()`**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\UserGame;
|
||||
use App\Policies\UserGamePolicy;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
Gate::policy(UserGame::class, UserGamePolicy::class);
|
||||
Gate::policy(\App\Models\Review::class, \App\Policies\ReviewPolicy::class);
|
||||
Gate::policy(\App\Models\UserList::class, \App\Policies\ListPolicy::class);
|
||||
Gate::policy(\App\Models\GameComment::class, \App\Policies\GameCommentPolicy::class);
|
||||
|
||||
RateLimiter::for('api', function (Request $request) {
|
||||
$key = $request->bearerToken() ?? $request->get('api_key') ?? $request->ip();
|
||||
return Limit::perMinute(60)->by($key)->response(function () {
|
||||
return response()->json(['error' => 'Too many requests. Limit: 60/min.'], 429);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: API routes file
|
||||
|
||||
**Files:**
|
||||
- Create: `routes/api.php`
|
||||
- Modify: `routes/web.php` (add `require` at bottom)
|
||||
|
||||
- [ ] **Step 1: Create `routes/api.php`**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\V1\GameApiController;
|
||||
use App\Http\Controllers\Api\V1\MetaApiController;
|
||||
use App\Http\Controllers\Api\V1\UserApiController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('api/v1')->middleware(['api.key', 'throttle:api'])->group(function () {
|
||||
// Games
|
||||
Route::get('/games', [GameApiController::class, 'index']);
|
||||
Route::get('/games/search', [GameApiController::class, 'search']);
|
||||
Route::get('/games/top-rated', [GameApiController::class, 'topRated']);
|
||||
Route::get('/games/new-releases', [GameApiController::class, 'newReleases']);
|
||||
Route::get('/games/{game:slug}', [GameApiController::class, 'show']);
|
||||
|
||||
// Meta
|
||||
Route::get('/genres', [MetaApiController::class, 'genres']);
|
||||
Route::get('/platforms', [MetaApiController::class, 'platforms']);
|
||||
|
||||
// Users (public profiles)
|
||||
Route::get('/users/{user:slug}', [UserApiController::class, 'show']);
|
||||
Route::get('/users/{user:slug}/library', [UserApiController::class, 'library']);
|
||||
});
|
||||
```
|
||||
|
||||
Note: The spec uses `{user:username}` but the User model's route key is `slug` (renamed from username in migration `2026_05_30_000002`). Using `{user:slug}` is correct.
|
||||
|
||||
- [ ] **Step 2: Include `api.php` from `web.php`**
|
||||
|
||||
Add at the very bottom of `routes/web.php`:
|
||||
|
||||
```php
|
||||
require __DIR__.'/api.php';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: `ApiKeyController`
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Http/Controllers/ApiKeyController.php`
|
||||
|
||||
- [ ] **Step 1: Create controller**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ApiKey;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class ApiKeyController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$keys = auth()->user()->apiKeys()->latest()->get();
|
||||
return view('settings.api-keys', compact('keys'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate(['name' => 'required|string|max:50']);
|
||||
|
||||
$apiKey = ApiKey::generate(auth()->user(), $request->name);
|
||||
|
||||
return redirect()->route('settings.api-keys')->with('new_api_key', $apiKey->key);
|
||||
}
|
||||
|
||||
public function destroy(ApiKey $apiKey)
|
||||
{
|
||||
Gate::authorize('delete', $apiKey);
|
||||
$apiKey->delete();
|
||||
return redirect()->route('settings.api-keys')->with('success', 'API key deleted.');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `apiKeys` relation to `User` model**
|
||||
|
||||
In `app/Models/User.php`, add:
|
||||
|
||||
```php
|
||||
public function apiKeys(): HasMany { return $this->hasMany(\App\Models\ApiKey::class); }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add policy for `ApiKey`**
|
||||
|
||||
Create `app/Policies/ApiKeyPolicy.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\ApiKey;
|
||||
use App\Models\User;
|
||||
|
||||
class ApiKeyPolicy
|
||||
{
|
||||
public function delete(User $user, ApiKey $apiKey): bool
|
||||
{
|
||||
return $user->id === $apiKey->user_id;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Register policy in `AppServiceProvider`**
|
||||
|
||||
Add to `boot()` in `AppServiceProvider.php`:
|
||||
|
||||
```php
|
||||
Gate::policy(\App\Models\ApiKey::class, \App\Policies\ApiKeyPolicy::class);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Web routes for API key management
|
||||
|
||||
**Files:**
|
||||
- Modify: `routes/web.php`
|
||||
|
||||
- [ ] **Step 1: Add API key management routes inside `auth` middleware group**
|
||||
|
||||
Add before the `require __DIR__.'/auth.php'` line:
|
||||
|
||||
```php
|
||||
// API Key Management
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/settings/api-keys', [\App\Http\Controllers\ApiKeyController::class, 'index'])->name('settings.api-keys');
|
||||
Route::post('/settings/api-keys', [\App\Http\Controllers\ApiKeyController::class, 'store'])->name('settings.api-keys.store');
|
||||
Route::delete('/settings/api-keys/{apiKey}', [\App\Http\Controllers\ApiKeyController::class, 'destroy'])->name('settings.api-keys.destroy');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: `settings/api-keys.blade.php` view
|
||||
|
||||
**Files:**
|
||||
- Create: `resources/views/settings/api-keys.blade.php`
|
||||
|
||||
- [ ] **Step 1: Create view**
|
||||
|
||||
```blade
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'API Keys — LoreKeepers')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-3xl mx-auto px-4 py-10">
|
||||
|
||||
<h1 class="text-2xl font-bold text-white mb-1">API Keys</h1>
|
||||
<p class="text-slate-400 text-sm mb-8">Use API keys to access the <a href="{{ route('api.docs') }}" class="text-purple-400 hover:text-purple-300 underline">LoreKeepers API</a>. Keep them secret — treat them like passwords.</p>
|
||||
|
||||
{{-- New key revealed --}}
|
||||
@if(session('new_api_key'))
|
||||
<div class="mb-8 p-4 bg-purple-900/30 border border-purple-500/40 rounded-xl" x-data="{ copied: false }">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<svg class="w-4 h-4 text-yellow-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="text-yellow-300 text-sm font-semibold">This key will only be shown once. Copy it now.</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-3">
|
||||
<code class="flex-1 bg-[#0f0f13] border border-white/10 rounded-lg px-4 py-2.5 text-sm text-green-300 font-mono break-all select-all">{{ session('new_api_key') }}</code>
|
||||
<button @click="navigator.clipboard.writeText('{{ session('new_api_key') }}'); copied = true; setTimeout(() => copied = false, 2000)"
|
||||
class="flex-shrink-0 px-3 py-2.5 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors font-medium">
|
||||
<span x-show="!copied">Copy</span>
|
||||
<span x-show="copied">Copied!</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Existing keys --}}
|
||||
<div class="bg-[#16161d] border border-white/5 rounded-xl mb-8 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-white/5">
|
||||
<h2 class="text-sm font-semibold text-slate-200">Your API Keys</h2>
|
||||
</div>
|
||||
@forelse($keys as $key)
|
||||
<div class="px-5 py-4 flex items-center justify-between gap-4 @if(!$loop->last) border-b border-white/5 @endif">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-white">{{ $key->name }}</div>
|
||||
<div class="text-xs text-slate-500 mt-0.5">
|
||||
Created {{ $key->created_at->format('M j, Y') }}
|
||||
@if($key->last_used_at)
|
||||
· Last used {{ $key->last_used_at->diffForHumans() }}
|
||||
@else
|
||||
· Never used
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('settings.api-keys.destroy', $key) }}" onsubmit="return confirm('Delete this API key? This cannot be undone.')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-xs px-3 py-1.5 bg-red-900/40 hover:bg-red-800/60 border border-red-700/40 text-red-400 hover:text-red-300 rounded-lg transition-colors">
|
||||
Revoke
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@empty
|
||||
<div class="px-5 py-8 text-center text-slate-500 text-sm">
|
||||
No API keys yet. Create one below.
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- Create new key --}}
|
||||
<div class="bg-[#16161d] border border-white/5 rounded-xl overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-white/5">
|
||||
<h2 class="text-sm font-semibold text-slate-200">Create New Key</h2>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('settings.api-keys.store') }}" class="px-5 py-4">
|
||||
@csrf
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<label for="name" class="block text-xs text-slate-400 mb-1.5">Key Label</label>
|
||||
<input type="text" id="name" name="name" value="{{ old('name') }}"
|
||||
placeholder="e.g. My App, Personal Script"
|
||||
class="w-full bg-[#0f0f13] border border-white/10 rounded-lg px-3 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"
|
||||
maxlength="50">
|
||||
@error('name')
|
||||
<p class="text-red-400 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors font-medium whitespace-nowrap">
|
||||
Generate Key
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: `GameApiController`
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Http/Controllers/Api/V1/GameApiController.php`
|
||||
|
||||
- [ ] **Step 1: Create directory and controller**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Game;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class GameApiController extends Controller
|
||||
{
|
||||
private function formatGame(Game $game): array
|
||||
{
|
||||
return [
|
||||
'id' => $game->id,
|
||||
'title' => $game->title,
|
||||
'slug' => $game->slug,
|
||||
'cover_url' => $game->cover_url,
|
||||
'release_year' => $game->release_year,
|
||||
'release_date' => $game->release_date?->toDateString(),
|
||||
'rating_avg' => $game->rating_avg ? (float) $game->rating_avg : null,
|
||||
'rating_count' => $game->rating_count,
|
||||
'library_count' => $game->library_count,
|
||||
'genres' => $game->genres->pluck('name'),
|
||||
'platforms' => $game->platforms->pluck('name'),
|
||||
];
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Game::with(['genres', 'platforms'])->published();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
match ($request->get('sort', 'popular')) {
|
||||
'rating' => $query->orderByDesc('rating_avg'),
|
||||
'release' => $query->orderByDesc('release_date'),
|
||||
'title' => $query->orderBy('title'),
|
||||
default => $query->orderByDesc('library_count'),
|
||||
};
|
||||
|
||||
$paginator = $query->paginate(20);
|
||||
|
||||
return response()->json([
|
||||
'data' => $paginator->map(fn ($game) => $this->formatGame($game))->values(),
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Game $game): JsonResponse
|
||||
{
|
||||
$game->load(['genres', 'platforms', 'companies', 'screenshots', 'reviews.user']);
|
||||
|
||||
return response()->json([
|
||||
'data' => array_merge($this->formatGame($game), [
|
||||
'description' => $game->summary,
|
||||
'companies' => $game->companies->map(fn ($c) => [
|
||||
'name' => $c->name,
|
||||
'role' => $c->pivot->role,
|
||||
]),
|
||||
'screenshots' => $game->screenshots->pluck('url'),
|
||||
'reviews' => $game->reviews->take(5)->map(fn ($r) => [
|
||||
'user' => $r->user->slug,
|
||||
'body' => $r->body,
|
||||
'rating' => $r->rating,
|
||||
'created_at' => $r->created_at->toIso8601String(),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function search(Request $request): JsonResponse
|
||||
{
|
||||
$q = trim((string) $request->get('q', ''));
|
||||
|
||||
if (mb_strlen($q) < 2) {
|
||||
return response()->json(['error' => 'Query must be at least 2 characters.'], 422);
|
||||
}
|
||||
|
||||
$games = Game::with(['genres', 'platforms'])
|
||||
->where('title', 'like', '%' . $q . '%')
|
||||
->published()
|
||||
->orderBy('title')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'data' => $games->map(fn ($game) => $this->formatGame($game))->values(),
|
||||
'meta' => ['total' => $games->count()],
|
||||
]);
|
||||
}
|
||||
|
||||
public function topRated(Request $request): JsonResponse
|
||||
{
|
||||
$paginator = Game::with(['genres', 'platforms'])
|
||||
->published()
|
||||
->where('rating_count', '>=', 1)
|
||||
->orderByDesc('rating_avg')
|
||||
->orderByDesc('rating_count')
|
||||
->paginate(20);
|
||||
|
||||
return response()->json([
|
||||
'data' => $paginator->map(fn ($game) => $this->formatGame($game))->values(),
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function newReleases(Request $request): JsonResponse
|
||||
{
|
||||
$paginator = Game::with(['genres', 'platforms'])
|
||||
->published()
|
||||
->where('release_date', '<=', now())
|
||||
->orderByDesc('release_date')
|
||||
->paginate(20);
|
||||
|
||||
return response()->json([
|
||||
'data' => $paginator->map(fn ($game) => $this->formatGame($game))->values(),
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: `MetaApiController`
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Http/Controllers/Api/V1/MetaApiController.php`
|
||||
|
||||
- [ ] **Step 1: Create controller**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Platform;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class MetaApiController extends Controller
|
||||
{
|
||||
public function genres(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'data' => Genre::orderBy('name')->get(['id', 'name', 'slug']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function platforms(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'data' => Platform::orderBy('name')->get(['id', 'name', 'slug']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: `UserApiController`
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Http/Controllers/Api/V1/UserApiController.php`
|
||||
|
||||
- [ ] **Step 1: Create controller**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UserApiController extends Controller
|
||||
{
|
||||
public function show(User $user): JsonResponse
|
||||
{
|
||||
$library = $user->library();
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'username' => $user->slug,
|
||||
'name' => $user->name,
|
||||
'avatar_url' => $user->avatar,
|
||||
'joined_at' => $user->created_at->toIso8601String(),
|
||||
'stats' => [
|
||||
'played' => (clone $library)->where('status', 'played')->count(),
|
||||
'playing' => (clone $library)->where('status', 'playing')->count(),
|
||||
'want_to_play' => (clone $library)->where('status', 'want_to_play')->count(),
|
||||
'total' => $user->library()->count(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function library(User $user, Request $request): JsonResponse
|
||||
{
|
||||
$paginator = $user->library()
|
||||
->with('game')
|
||||
->orderByDesc('updated_at')
|
||||
->paginate(20);
|
||||
|
||||
return response()->json([
|
||||
'data' => $paginator->map(fn ($ug) => [
|
||||
'game' => [
|
||||
'title' => $ug->game->title,
|
||||
'slug' => $ug->game->slug,
|
||||
'cover_url' => $ug->game->cover_url,
|
||||
],
|
||||
'status' => $ug->status,
|
||||
'rating' => $ug->rating,
|
||||
'updated_at' => $ug->updated_at->toIso8601String(),
|
||||
])->values(),
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 13: API docs page
|
||||
|
||||
**Files:**
|
||||
- Create: `resources/views/api/docs.blade.php`
|
||||
|
||||
- [ ] **Step 1: Add route to `web.php` (public, no auth)**
|
||||
|
||||
Add near the top of `routes/web.php`:
|
||||
|
||||
```php
|
||||
Route::get('/api/docs', function () {
|
||||
return view('api.docs');
|
||||
})->name('api.docs');
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the docs view**
|
||||
|
||||
```blade
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'API Documentation — LoreKeepers')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-4xl mx-auto px-4 py-10">
|
||||
|
||||
<div class="mb-10">
|
||||
<h1 class="text-3xl font-bold text-white mb-3">API Documentation</h1>
|
||||
<p class="text-slate-400">Programmatic access to the LoreKeepers game database and user libraries.</p>
|
||||
</div>
|
||||
|
||||
{{-- Auth --}}
|
||||
<section class="mb-10">
|
||||
<h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Authentication</h2>
|
||||
<p class="text-slate-400 text-sm mb-3">All API requests require an API key. Generate one in <a href="{{ route('settings.api-keys') }}" class="text-purple-400 hover:text-purple-300 underline">Settings → API Keys</a>.</p>
|
||||
<p class="text-slate-400 text-sm mb-3">Pass the key via the <code class="text-purple-300 bg-white/5 px-1.5 py-0.5 rounded text-xs">Authorization</code> header:</p>
|
||||
<pre class="bg-[#16161d] border border-white/10 rounded-xl p-4 text-sm text-green-300 overflow-x-auto font-mono">Authorization: Bearer YOUR_API_KEY</pre>
|
||||
<p class="text-slate-500 text-sm mt-3">Alternatively, append <code class="text-purple-300 bg-white/5 px-1.5 py-0.5 rounded text-xs">?api_key=YOUR_API_KEY</code> as a query parameter.</p>
|
||||
</section>
|
||||
|
||||
{{-- Base URL + Rate Limit --}}
|
||||
<section class="mb-10">
|
||||
<h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Base URL & Rate Limiting</h2>
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="bg-[#16161d] border border-white/5 rounded-xl p-4">
|
||||
<div class="text-xs text-slate-500 mb-1 uppercase tracking-wide">Base URL</div>
|
||||
<code class="text-purple-300 font-mono text-sm">https://gamelog.ro/api/v1</code>
|
||||
</div>
|
||||
<div class="bg-[#16161d] border border-white/5 rounded-xl p-4">
|
||||
<div class="text-xs text-slate-500 mb-1 uppercase tracking-wide">Rate Limit</div>
|
||||
<span class="text-white text-sm font-medium">60 requests / minute</span>
|
||||
<p class="text-slate-500 text-xs mt-1">Returns HTTP 429 when exceeded.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- Endpoints --}}
|
||||
<section class="mb-10">
|
||||
<h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Endpoints</h2>
|
||||
|
||||
@php
|
||||
$endpoints = [
|
||||
['GET', '/games', 'List games', 'Filter: genre (id), platform (id), year, sort (rating|release|title|popular). Paginated 20/page.'],
|
||||
['GET', '/games/search', 'Search games', 'Required: q (min 2 chars). Returns up to 20 matches.'],
|
||||
['GET', '/games/top-rated', 'Top rated games', 'Games with ≥1 rating, sorted by avg rating. Paginated 20/page.'],
|
||||
['GET', '/games/new-releases', 'New releases', 'Games released ≤ today, sorted newest first. Paginated 20/page.'],
|
||||
['GET', '/games/{slug}', 'Game detail', 'Full game info: companies, screenshots, latest 5 reviews.'],
|
||||
['GET', '/genres', 'All genres', 'Returns id, name, slug for all genres.'],
|
||||
['GET', '/platforms', 'All platforms', 'Returns id, name, slug for all platforms.'],
|
||||
['GET', '/users/{slug}', 'User profile', 'Public profile: username, name, avatar, stats, joined date.'],
|
||||
['GET', '/users/{slug}/library', 'User library', 'User\'s game library. Paginated 20/page. Returns game + status + rating.'],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<div class="space-y-3">
|
||||
@foreach($endpoints as $ep)
|
||||
<div class="bg-[#16161d] border border-white/5 rounded-xl p-4 flex gap-4 items-start">
|
||||
<span class="flex-shrink-0 text-xs font-mono font-bold px-2 py-1 rounded bg-purple-900/40 text-purple-300 border border-purple-700/30 mt-0.5">{{ $ep[0] }}</span>
|
||||
<div class="min-w-0">
|
||||
<code class="text-green-300 font-mono text-sm">/api/v1{{ $ep[1] }}</code>
|
||||
<div class="text-white text-sm font-medium mt-0.5">{{ $ep[2] }}</div>
|
||||
<div class="text-slate-500 text-xs mt-1">{{ $ep[3] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- Response format --}}
|
||||
<section class="mb-10">
|
||||
<h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Response Format</h2>
|
||||
<p class="text-slate-400 text-sm mb-4">All list endpoints return paginated responses wrapped in <code class="text-purple-300 bg-white/5 px-1.5 py-0.5 rounded text-xs">data</code> + <code class="text-purple-300 bg-white/5 px-1.5 py-0.5 rounded text-xs">meta</code>:</p>
|
||||
<pre class="bg-[#16161d] border border-white/10 rounded-xl p-4 text-sm text-green-300 overflow-x-auto font-mono">{
|
||||
"data": [ ... ],
|
||||
"meta": {
|
||||
"current_page": 1,
|
||||
"last_page": 5,
|
||||
"per_page": 20,
|
||||
"total": 93
|
||||
}
|
||||
}</pre>
|
||||
</section>
|
||||
|
||||
{{-- Example --}}
|
||||
<section class="mb-10">
|
||||
<h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Example Request</h2>
|
||||
<pre class="bg-[#16161d] border border-white/10 rounded-xl p-4 text-sm text-green-300 overflow-x-auto font-mono">curl https://gamelog.ro/api/v1/games/top-rated \
|
||||
-H "Authorization: Bearer YOUR_API_KEY"</pre>
|
||||
</section>
|
||||
|
||||
{{-- Errors --}}
|
||||
<section>
|
||||
<h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Error Codes</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-slate-500 text-xs uppercase tracking-wide border-b border-white/10">
|
||||
<th class="pb-2 pr-6">Status</th>
|
||||
<th class="pb-2">Meaning</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-slate-300">
|
||||
<tr class="border-b border-white/5"><td class="py-2.5 pr-6 font-mono text-yellow-400">401</td><td>Missing or invalid API key</td></tr>
|
||||
<tr class="border-b border-white/5"><td class="py-2.5 pr-6 font-mono text-yellow-400">404</td><td>Resource not found</td></tr>
|
||||
<tr class="border-b border-white/5"><td class="py-2.5 pr-6 font-mono text-yellow-400">422</td><td>Validation error (e.g. q too short)</td></tr>
|
||||
<tr><td class="py-2.5 pr-6 font-mono text-yellow-400">429</td><td>Rate limit exceeded (60 req/min)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 14: Add "API" link to nav and footer
|
||||
|
||||
**Files:**
|
||||
- Modify: `resources/views/layouts/app.blade.php`
|
||||
|
||||
- [ ] **Step 1: Add to footer links**
|
||||
|
||||
Find this block in `resources/views/layouts/app.blade.php` (around line 185):
|
||||
|
||||
```blade
|
||||
<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>
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```blade
|
||||
<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>
|
||||
<a href="{{ route('api.docs') }}" class="hover:text-slate-300 transition-colors">Developers</a>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add "API Keys" link to user dropdown (after Settings)**
|
||||
|
||||
Find in `resources/views/layouts/app.blade.php` (around line 140):
|
||||
|
||||
```blade
|
||||
<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>
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```blade
|
||||
<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>
|
||||
<a href="{{ route('settings.api-keys') }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">API Keys</a>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 15: Verify and commit
|
||||
|
||||
- [ ] **Step 1: Run syntax check**
|
||||
|
||||
```bash
|
||||
php artisan route:list --path=api
|
||||
```
|
||||
|
||||
Expected: list of `/api/v1/*` routes showing `api.key` and `throttle:api` middleware.
|
||||
|
||||
- [ ] **Step 2: Run migrations**
|
||||
|
||||
```bash
|
||||
php artisan migrate:status | grep api_keys
|
||||
```
|
||||
|
||||
Expected: `Yes` in the `Ran?` column for `2026_05_31_000001_create_api_keys_table`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "Add public API v1 with API key auth, rate limiting, and docs page"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Push**
|
||||
|
||||
```bash
|
||||
git -c http.extraHeader="Authorization: token cc6bd9ac81340acb043c5f630586b2d5f1cb8280" push origin master
|
||||
```
|
||||
+1
-17
@@ -1,18 +1,2 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Disallow: /dashboard
|
||||
Disallow: /profile
|
||||
Disallow: /library
|
||||
Disallow: /notifications
|
||||
Disallow: /activity
|
||||
Disallow: /login
|
||||
Disallow: /register
|
||||
Disallow: /forgot-password
|
||||
Disallow: /reset-password
|
||||
Disallow: /verify-email
|
||||
Disallow: /games/search
|
||||
Disallow: /lists
|
||||
Disallow: /*?*page=*
|
||||
|
||||
Sitemap: https://gamelog.ro/sitemap.xml
|
||||
Disallow:
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Activity Feed — LoreKeepers')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Activity Feed</h1>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('activity.index') }}"
|
||||
class="text-sm px-3 py-1.5 rounded-lg {{ request()->routeIs('activity.index') ? 'bg-purple-600 text-white' : 'text-slate-400 hover:text-slate-200' }} transition-colors">
|
||||
Following
|
||||
</a>
|
||||
<a href="{{ route('activity.global') }}"
|
||||
class="text-sm px-3 py-1.5 rounded-lg {{ request()->routeIs('activity.global') ? 'bg-purple-600 text-white' : 'text-slate-400 hover:text-slate-200' }} transition-colors">
|
||||
Global
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($activities->isEmpty())
|
||||
<div class="text-center py-20">
|
||||
<div class="w-16 h-16 rounded-full bg-[#16161d] flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-slate-300 mb-2">No activity yet</h3>
|
||||
<p class="text-slate-500 mb-6">Follow some users to see their activity here.</p>
|
||||
<a href="{{ route('games.index') }}" 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">
|
||||
Browse Games
|
||||
</a>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach($activities as $activity)
|
||||
@php
|
||||
$game = $activity->subject;
|
||||
@endphp
|
||||
<div class="bg-[#16161d] border border-white/5 rounded-xl p-4 flex items-start gap-4">
|
||||
{{-- User avatar --}}
|
||||
<a href="{{ $activity->user ? route('users.show', $activity->user->slug) : '#' }}" class="flex-shrink-0">
|
||||
@if($activity->user?->avatar)
|
||||
<img src="{{ $activity->user->avatar }}" class="w-9 h-9 rounded-full object-cover border border-white/10" alt="">
|
||||
@else
|
||||
<div class="w-9 h-9 rounded-full bg-purple-700 flex items-center justify-center text-sm font-bold text-white">
|
||||
{{ strtoupper(substr($activity->user?->name ?? '?', 0, 1)) }}
|
||||
</div>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
{{-- Game thumbnail --}}
|
||||
@if($game && $game->cover_thumb_url)
|
||||
<a href="{{ route('games.show', $game->slug) }}" class="flex-shrink-0">
|
||||
<img src="{{ $game->cover_thumb_url }}" alt="{{ $game->title }}"
|
||||
class="w-10 h-14 object-cover rounded border border-white/10 bg-[#0f0f13]">
|
||||
</a>
|
||||
@endif
|
||||
|
||||
{{-- Activity text --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-slate-300">
|
||||
<a href="{{ $activity->user ? route('users.show', $activity->user->slug) : '#' }}"
|
||||
class="font-semibold text-white hover:text-purple-300 transition-colors">
|
||||
{{ $activity->user?->slug ?? 'Unknown' }}
|
||||
</a>
|
||||
|
||||
@if($activity->type === 'added_to_library')
|
||||
added
|
||||
@if($game)
|
||||
<a href="{{ route('games.show', $game->slug) }}" class="text-purple-400 hover:text-purple-300 font-medium transition-colors">{{ $game->title }}</a>
|
||||
@endif
|
||||
to their library
|
||||
@if(!empty($activity->data['status']))
|
||||
<span class="text-slate-500">as <span class="text-slate-400">{{ str_replace('_', ' ', $activity->data['status']) }}</span></span>
|
||||
@endif
|
||||
|
||||
@elseif($activity->type === 'rated_game')
|
||||
rated
|
||||
@if($game)
|
||||
<a href="{{ route('games.show', $game->slug) }}" class="text-purple-400 hover:text-purple-300 font-medium transition-colors">{{ $game->title }}</a>
|
||||
@endif
|
||||
<span class="text-yellow-400 font-semibold">{{ $activity->data['rating'] ?? '' }}/10</span>
|
||||
|
||||
@elseif($activity->type === 'reviewed_game')
|
||||
reviewed
|
||||
@if($game)
|
||||
<a href="{{ route('games.show', $game->slug) }}" class="text-purple-400 hover:text-purple-300 font-medium transition-colors">{{ $game->title }}</a>
|
||||
@endif
|
||||
@if(!empty($activity->data['rating']))
|
||||
<span class="text-slate-500">— <span class="text-yellow-400">{{ $activity->data['rating'] }}/10</span></span>
|
||||
@endif
|
||||
|
||||
@else
|
||||
{{ str_replace('_', ' ', $activity->type) }}
|
||||
@if($game)
|
||||
<a href="{{ route('games.show', $game->slug) }}" class="text-purple-400 hover:text-purple-300 font-medium transition-colors">{{ $game->title }}</a>
|
||||
@endif
|
||||
@endif
|
||||
</p>
|
||||
<p class="text-xs text-slate-600 mt-1">{{ $activity->created_at?->diffForHumans() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
{{ $activities->links() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,108 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'API Documentation — LoreKeepers')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-4xl mx-auto px-4 py-10">
|
||||
|
||||
<div class="mb-10">
|
||||
<h1 class="text-3xl font-bold text-white mb-3">API Documentation</h1>
|
||||
<p class="text-slate-400">Programmatic access to the LoreKeepers game database and user libraries.</p>
|
||||
</div>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Authentication</h2>
|
||||
<p class="text-slate-400 text-sm mb-3">All API requests require an API key. @auth Generate one in <a href="{{ route('settings.api-keys') }}" class="text-purple-400 hover:text-purple-300 underline">Settings → API Keys</a>. @else <a href="{{ route('register') }}" class="text-purple-400 hover:text-purple-300 underline">Sign up</a> and generate one in Settings → API Keys. @endauth</p>
|
||||
<p class="text-slate-400 text-sm mb-3">Pass the key via the <code class="text-purple-300 bg-white/5 px-1.5 py-0.5 rounded text-xs">Authorization</code> header:</p>
|
||||
<pre class="bg-[#16161d] border border-white/10 rounded-xl p-4 text-sm text-green-300 overflow-x-auto font-mono">Authorization: Bearer YOUR_API_KEY</pre>
|
||||
<p class="text-slate-500 text-sm mt-3">Alternatively, append <code class="text-purple-300 bg-white/5 px-1.5 py-0.5 rounded text-xs">?api_key=YOUR_API_KEY</code> as a query parameter.</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Base URL & Rate Limiting</h2>
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="bg-[#16161d] border border-white/5 rounded-xl p-4">
|
||||
<div class="text-xs text-slate-500 mb-1 uppercase tracking-wide">Base URL</div>
|
||||
<code class="text-purple-300 font-mono text-sm">https://gamelog.ro/api/v1</code>
|
||||
</div>
|
||||
<div class="bg-[#16161d] border border-white/5 rounded-xl p-4">
|
||||
<div class="text-xs text-slate-500 mb-1 uppercase tracking-wide">Rate Limit</div>
|
||||
<span class="text-white text-sm font-medium">60 requests / minute</span>
|
||||
<p class="text-slate-500 text-xs mt-1">Returns HTTP 429 when exceeded.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Endpoints</h2>
|
||||
|
||||
@php
|
||||
$endpoints = [
|
||||
['GET', '/games', 'List games', 'Filters: genre (id), platform (id), year, sort (rating|release|title|popular). Paginated 20/page.'],
|
||||
['GET', '/games/search', 'Search games', 'Required: q (min 2 chars). Returns up to 20 matches.'],
|
||||
['GET', '/games/top-rated', 'Top rated games', 'Games with ≥1 rating, sorted by avg rating. Paginated 20/page.'],
|
||||
['GET', '/games/new-releases', 'New releases', 'Games released ≤ today, sorted newest first. Paginated 20/page.'],
|
||||
['GET', '/games/{slug}', 'Game detail', 'Full game info: companies, screenshots, latest 5 reviews.'],
|
||||
['GET', '/genres', 'All genres', 'Returns id, name, slug for all genres.'],
|
||||
['GET', '/platforms', 'All platforms', 'Returns id, name, slug for all platforms.'],
|
||||
['GET', '/users/{slug}', 'User profile', 'Public profile: username, name, avatar, stats, joined date.'],
|
||||
['GET', '/users/{slug}/library', 'User library', "User's game library. Paginated 20/page. Returns game + status + rating."],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<div class="space-y-3">
|
||||
@foreach($endpoints as $ep)
|
||||
<div class="bg-[#16161d] border border-white/5 rounded-xl p-4 flex gap-4 items-start">
|
||||
<span class="flex-shrink-0 text-xs font-mono font-bold px-2 py-1 rounded bg-purple-900/40 text-purple-300 border border-purple-700/30 mt-0.5">{{ $ep[0] }}</span>
|
||||
<div class="min-w-0">
|
||||
<code class="text-green-300 font-mono text-sm">/api/v1{{ $ep[1] }}</code>
|
||||
<div class="text-white text-sm font-medium mt-0.5">{{ $ep[2] }}</div>
|
||||
<div class="text-slate-500 text-xs mt-1">{{ $ep[3] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Response Format</h2>
|
||||
<p class="text-slate-400 text-sm mb-4">All list endpoints return paginated responses wrapped in <code class="text-purple-300 bg-white/5 px-1.5 py-0.5 rounded text-xs">data</code> + <code class="text-purple-300 bg-white/5 px-1.5 py-0.5 rounded text-xs">meta</code>:</p>
|
||||
<pre class="bg-[#16161d] border border-white/10 rounded-xl p-4 text-sm text-green-300 overflow-x-auto font-mono">{
|
||||
"data": [ ... ],
|
||||
"meta": {
|
||||
"current_page": 1,
|
||||
"last_page": 5,
|
||||
"per_page": 20,
|
||||
"total": 93
|
||||
}
|
||||
}</pre>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Example Request</h2>
|
||||
<pre class="bg-[#16161d] border border-white/10 rounded-xl p-4 text-sm text-green-300 overflow-x-auto font-mono">curl https://gamelog.ro/api/v1/games/top-rated \
|
||||
-H "Authorization: Bearer YOUR_API_KEY"</pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Error Codes</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-slate-500 text-xs uppercase tracking-wide border-b border-white/10">
|
||||
<th class="pb-2 pr-6">Status</th>
|
||||
<th class="pb-2">Meaning</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-slate-300">
|
||||
<tr class="border-b border-white/5"><td class="py-2.5 pr-6 font-mono text-yellow-400">401</td><td>Missing or invalid API key</td></tr>
|
||||
<tr class="border-b border-white/5"><td class="py-2.5 pr-6 font-mono text-yellow-400">404</td><td>Resource not found</td></tr>
|
||||
<tr class="border-b border-white/5"><td class="py-2.5 pr-6 font-mono text-yellow-400">422</td><td>Validation error (e.g. q too short)</td></tr>
|
||||
<tr><td class="py-2.5 pr-6 font-mono text-yellow-400">429</td><td>Rate limit exceeded (60 req/min)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,206 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Your Library vs ' . ($user->slug ?? $user->name) . ' — LoreKeepers')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10"
|
||||
x-data="{ tab: 'both' }">
|
||||
|
||||
{{-- Header --}}
|
||||
<div class="mb-8">
|
||||
<a href="{{ route('users.show', $user->slug) }}"
|
||||
class="text-sm text-slate-500 hover:text-slate-300 transition-colors mb-2 inline-block">
|
||||
← {{ $user->slug ?? $user->name }}'s profile
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-white">
|
||||
Your Library vs <span class="text-purple-400">{{ $user->slug ?? $user->name }}</span>'s Library
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{{-- Stats Row --}}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-[#16161d] border border-white/10 rounded-xl p-4 text-center">
|
||||
<div class="text-2xl font-bold text-white">{{ $stats['both_count'] }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1 uppercase tracking-wide">In Common</div>
|
||||
</div>
|
||||
<div class="bg-[#16161d] border border-white/10 rounded-xl p-4 text-center">
|
||||
<div class="text-2xl font-bold text-white">{{ $stats['they_not_you'] }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1 uppercase tracking-wide">They Have, You Don't</div>
|
||||
</div>
|
||||
<div class="bg-[#16161d] border border-white/10 rounded-xl p-4 text-center">
|
||||
<div class="text-2xl font-bold text-white">{{ $stats['you_not_them'] }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1 uppercase tracking-wide">You Have, They Don't</div>
|
||||
</div>
|
||||
<div class="bg-[#16161d] border border-white/10 rounded-xl p-4 text-center">
|
||||
@if($stats['avg_rating_diff'] !== null)
|
||||
<div class="text-2xl font-bold text-white">{{ $stats['avg_rating_diff'] }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1 uppercase tracking-wide">Avg Rating Diff</div>
|
||||
@else
|
||||
<div class="text-2xl font-bold text-slate-600">—</div>
|
||||
<div class="text-xs text-slate-500 mt-1 uppercase tracking-wide">Avg Rating Diff</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Tabs --}}
|
||||
<div class="flex gap-1 mb-6 bg-[#16161d] border border-white/10 rounded-xl p-1 w-fit">
|
||||
<button @click="tab = 'both'"
|
||||
:class="tab === 'both' ? 'bg-purple-600 text-white' : 'text-slate-400 hover:text-white'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors">
|
||||
In Common ({{ $stats['both_count'] }})
|
||||
</button>
|
||||
<button @click="tab = 'they'"
|
||||
:class="tab === 'they' ? 'bg-purple-600 text-white' : 'text-slate-400 hover:text-white'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors">
|
||||
They Played, You Haven't ({{ $stats['they_not_you'] }})
|
||||
</button>
|
||||
<button @click="tab = 'you'"
|
||||
:class="tab === 'you' ? 'bg-purple-600 text-white' : 'text-slate-400 hover:text-white'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors">
|
||||
You Played, They Haven't ({{ $stats['you_not_them'] }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Tab: In Common --}}
|
||||
<div x-show="tab === 'both'" x-transition>
|
||||
@if($both->isEmpty())
|
||||
<div class="text-center py-16 text-slate-500">No games in common yet.</div>
|
||||
@else
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
@foreach($both as $item)
|
||||
@php $game = $item['game']; @endphp
|
||||
<div class="bg-[#16161d] border border-white/5 rounded-xl overflow-hidden hover:border-white/20 transition-colors">
|
||||
<a href="{{ route('games.show', $game) }}">
|
||||
@if($game->cover_url)
|
||||
<img src="{{ $game->cover_url }}" alt="{{ $game->title }}" class="w-full aspect-[3/4] object-cover">
|
||||
@else
|
||||
<div class="w-full aspect-[3/4] bg-[#0f0f13] flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-slate-700" 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
|
||||
</a>
|
||||
<div class="p-3">
|
||||
<a href="{{ route('games.show', $game) }}"
|
||||
class="text-sm font-medium text-slate-200 hover:text-white line-clamp-2 block mb-2 transition-colors">
|
||||
{{ $game->title }}
|
||||
</a>
|
||||
<div class="flex items-center justify-between text-xs text-slate-500">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-slate-600">You</span>
|
||||
@if($item['myEntry']->rating)
|
||||
<span class="text-yellow-400 font-bold">{{ $item['myEntry']->rating }}</span>
|
||||
@else
|
||||
<span class="text-slate-600">—</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="w-px h-3 bg-white/10"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-slate-600">Them</span>
|
||||
@if($item['theirEntry']->rating)
|
||||
<span class="text-purple-400 font-bold">{{ $item['theirEntry']->rating }}</span>
|
||||
@else
|
||||
<span class="text-slate-600">—</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Tab: They Played, You Haven't --}}
|
||||
<div x-show="tab === 'they'" x-transition>
|
||||
@if($theyHaveNotYou->isEmpty())
|
||||
<div class="text-center py-16 text-slate-500">No games here.</div>
|
||||
@else
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
@foreach($theyHaveNotYou as $entry)
|
||||
<div class="bg-[#16161d] border border-white/5 rounded-xl overflow-hidden hover:border-white/20 transition-colors">
|
||||
<a href="{{ route('games.show', $entry->game) }}">
|
||||
@if($entry->game->cover_url)
|
||||
<img src="{{ $entry->game->cover_url }}" alt="{{ $entry->game->title }}" class="w-full aspect-[3/4] object-cover">
|
||||
@else
|
||||
<div class="w-full aspect-[3/4] bg-[#0f0f13] flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-slate-700" 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
|
||||
</a>
|
||||
<div class="p-3">
|
||||
<a href="{{ route('games.show', $entry->game) }}"
|
||||
class="text-sm font-medium text-slate-200 hover:text-white line-clamp-2 block mb-2 transition-colors">
|
||||
{{ $entry->game->title }}
|
||||
</a>
|
||||
@if($entry->rating)
|
||||
<div class="flex items-center gap-1 text-xs text-slate-500 mb-2">
|
||||
<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="font-semibold text-white">{{ $entry->rating }}</span><span>/10</span>
|
||||
</div>
|
||||
@endif
|
||||
<form method="POST" action="{{ route('library.store') }}">
|
||||
@csrf
|
||||
<input type="hidden" name="game_id" value="{{ $entry->game->id }}">
|
||||
<input type="hidden" name="status" value="want_to_play">
|
||||
<button type="submit"
|
||||
class="w-full text-xs px-2 py-1.5 bg-purple-600/20 hover:bg-purple-600/40 text-purple-300 hover:text-white border border-purple-600/30 rounded-lg transition-colors">
|
||||
+ Add to Library
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Tab: You Played, They Haven't --}}
|
||||
<div x-show="tab === 'you'" x-transition>
|
||||
@if($youHaveNotThem->isEmpty())
|
||||
<div class="text-center py-16 text-slate-500">No games here.</div>
|
||||
@else
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
@foreach($youHaveNotThem as $entry)
|
||||
<div class="bg-[#16161d] border border-white/5 rounded-xl overflow-hidden hover:border-white/20 transition-colors">
|
||||
<a href="{{ route('games.show', $entry->game) }}">
|
||||
@if($entry->game->cover_url)
|
||||
<img src="{{ $entry->game->cover_url }}" alt="{{ $entry->game->title }}" class="w-full aspect-[3/4] object-cover">
|
||||
@else
|
||||
<div class="w-full aspect-[3/4] bg-[#0f0f13] flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-slate-700" 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
|
||||
</a>
|
||||
<div class="p-3">
|
||||
<a href="{{ route('games.show', $entry->game) }}"
|
||||
class="text-sm font-medium text-slate-200 hover:text-white line-clamp-2 block mb-2 transition-colors">
|
||||
{{ $entry->game->title }}
|
||||
</a>
|
||||
@if($entry->rating)
|
||||
<div class="flex items-center gap-1 text-xs text-slate-500">
|
||||
<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="font-semibold text-white">{{ $entry->rating }}</span><span>/10</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
@@ -4,8 +4,8 @@
|
||||
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_url)
|
||||
<img src="{{ $game->cover_url }}"
|
||||
@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
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Browse Video Games — Filter by Genre, Platform & Year | LoreKeepers')
|
||||
@section('description', 'Browse our full library of video games. Filter by genre, platform, release year, and sort by rating or popularity. Find your next favorite game.')
|
||||
@section('canonical', route('games.index'))
|
||||
@section('og_description', 'Browse our full library of video games. Filter by genre, platform, release year, and sort by rating or popularity. Find your next favorite game.')
|
||||
@if($shouldNoindex ?? false)
|
||||
@section('robots', 'noindex,follow')
|
||||
@endif
|
||||
|
||||
@push('head_links')
|
||||
@if($games->currentPage() > 1)
|
||||
<link rel="prev" href="{{ $games->previousPageUrl() }}">
|
||||
@endif
|
||||
@if($games->hasMorePages())
|
||||
<link rel="next" href="{{ $games->nextPageUrl() }}">
|
||||
@endif
|
||||
@endpush
|
||||
@section('title', 'Browse Games — GameLog')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'New Video Game Releases — Latest Games | LoreKeepers')
|
||||
@section('description', 'Explore the latest video game releases sorted by release date. Track new games, add them to your library, and see what the LoreKeepers community thinks.')
|
||||
@section('canonical', route('games.new-releases'))
|
||||
@section('og_description', 'Explore the latest video game releases sorted by release date. Track new games, add them to your library, and see what the LoreKeepers community thinks.')
|
||||
@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 LoreKeepers database.</p>
|
||||
<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">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', $q ? "Search: {$q} — LoreKeepers" : 'Search Games — LoreKeepers')
|
||||
@section('robots', 'noindex,follow')
|
||||
@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">
|
||||
|
||||
@@ -1,33 +1,14 @@
|
||||
@extends('layouts.app')
|
||||
@php use Illuminate\Support\Str; @endphp
|
||||
|
||||
@section('title', $game->title . ($game->release_year ? ' (' . $game->release_year . ')' : '') . ' — Reviews, Rating & Info | LoreKeepers')
|
||||
@php
|
||||
$metaDesc = $game->summary ?? '';
|
||||
if (strlen($metaDesc) > 155) {
|
||||
$truncated = substr($metaDesc, 0, 155);
|
||||
$lastDot = strrpos($truncated, '.');
|
||||
$metaDesc = $lastDot > 100 ? substr($truncated, 0, $lastDot + 1) : Str::limit($metaDesc, 155);
|
||||
}
|
||||
if (!$metaDesc) $metaDesc = 'Track, rate and review ' . $game->title . ' on LoreKeepers. See community ratings, reviews, and similar games.';
|
||||
@endphp
|
||||
@section('description', $metaDesc)
|
||||
@section('og_title', $game->title . ' — LoreKeepers')
|
||||
@section('og_description', $game->summary ? Str::limit($game->summary, 200) : 'Track, rate and review ' . $game->title . ' on LoreKeepers.')
|
||||
@section('title', $game->title . ' — GameLog')
|
||||
@section('description', $game->summary ? Str::limit($game->summary, 155) : 'Track ' . $game->title . ' on GameLog.')
|
||||
@section('og_title', $game->title . ' — GameLog')
|
||||
@section('og_description', $game->summary ? Str::limit($game->summary, 155) : 'Track ' . $game->title . ' on GameLog.')
|
||||
@section('og_image', $game->cover_url ?? asset('images/og-default.jpg'))
|
||||
@section('og_type', 'website')
|
||||
@section('canonical', route('games.show', $game))
|
||||
@section('og_type', 'game')
|
||||
|
||||
@section('content')
|
||||
<nav class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-6 pb-0" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center gap-1.5 text-sm text-slate-500">
|
||||
<li><a href="{{ route('home') }}" class="hover:text-slate-300 transition-colors">Home</a></li>
|
||||
<li class="text-slate-700">/</li>
|
||||
<li><a href="{{ route('games.index') }}" class="hover:text-slate-300 transition-colors">Games</a></li>
|
||||
<li class="text-slate-700">/</li>
|
||||
<li class="text-slate-400 truncate max-w-xs">{{ $game->title }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
||||
|
||||
{{-- Main Info --}}
|
||||
@@ -215,28 +196,27 @@
|
||||
<div x-show="open" @click.outside="open = false" x-transition
|
||||
class="mt-1 w-56 bg-[#16161d] border border-white/10 rounded-lg shadow-xl overflow-hidden z-10"
|
||||
style="display:none;">
|
||||
<template x-if="lists.length === 0">
|
||||
<div class="px-4 py-3 text-sm text-slate-500">
|
||||
No lists yet.
|
||||
<a href="{{ route('lists.index') }}" class="text-purple-400 hover:text-purple-300">Create one</a>
|
||||
</div>
|
||||
</template>
|
||||
<template x-for="list in lists" :key="list.slug">
|
||||
<button type="button" @click="toggleList(list.slug)"
|
||||
@if($userLists->isEmpty())
|
||||
<div class="px-4 py-3 text-sm text-slate-500">
|
||||
No lists yet.
|
||||
<a href="{{ route('lists.index') }}" class="text-purple-400 hover:text-purple-300">Create one</a>
|
||||
</div>
|
||||
@else
|
||||
<template x-for="list in lists" :key="list.id">
|
||||
<button type="button" @click="toggleList(list.id)"
|
||||
class="w-full flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-white/5 transition-colors"
|
||||
:class="added[list.slug] ? 'text-purple-400' : 'text-slate-300'">
|
||||
<svg x-show="added[list.slug]" class="w-4 h-4 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
:class="added[list.id] ? 'text-purple-400' : 'text-slate-300'">
|
||||
<svg x-show="added[list.id]" class="w-4 h-4 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span x-show="!added[list.slug]" class="w-4 h-4 flex-shrink-0"></span>
|
||||
<span x-show="!added[list.id]" class="w-4 h-4 flex-shrink-0"></span>
|
||||
<span x-text="list.title" class="truncate"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="lists.length > 0">
|
||||
<div class="border-t border-white/5 px-4 py-2">
|
||||
<a href="{{ route('lists.index') }}" class="text-xs text-slate-500 hover:text-slate-300 transition-colors">Manage lists →</a>
|
||||
</div>
|
||||
</template>
|
||||
<div class="border-t border-white/5 px-4 py-2">
|
||||
<a href="{{ route('lists.index') }}" class="text-xs text-slate-500 hover:text-slate-300 transition-colors">Manage lists →</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
@@ -245,7 +225,7 @@
|
||||
|
||||
{{-- Companies --}}
|
||||
@if($game->companies->isNotEmpty())
|
||||
<div class="text-sm text-slate-500 space-y-1 mb-4">
|
||||
<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');
|
||||
@@ -259,87 +239,6 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Extended metadata --}}
|
||||
<div class="text-sm text-slate-400 space-y-2">
|
||||
@if($game->collection)
|
||||
<div class="flex gap-2">
|
||||
<span class="text-slate-500 w-36 flex-shrink-0">Series / Collection</span>
|
||||
<span class="text-slate-300">{{ $game->collection->name }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if($game->gameModes->isNotEmpty())
|
||||
<div class="flex gap-2">
|
||||
<span class="text-slate-500 w-36 flex-shrink-0">Game Modes</span>
|
||||
<span class="text-slate-300">{{ $game->gameModes->pluck('name')->join(', ') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if($game->themes->isNotEmpty())
|
||||
<div class="flex gap-2">
|
||||
<span class="text-slate-500 w-36 flex-shrink-0">Themes</span>
|
||||
<span class="text-slate-300">{{ $game->themes->pluck('name')->join(', ') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if($game->playerPerspectives->isNotEmpty())
|
||||
<div class="flex gap-2">
|
||||
<span class="text-slate-500 w-36 flex-shrink-0">Perspective</span>
|
||||
<span class="text-slate-300">{{ $game->playerPerspectives->pluck('name')->join(', ') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if($game->aggregated_rating)
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-slate-500 w-36 flex-shrink-0">Critics Score</span>
|
||||
<span class="px-2 py-0.5 rounded text-xs font-bold
|
||||
@if($game->aggregated_rating >= 75) bg-green-900/50 text-green-300
|
||||
@elseif($game->aggregated_rating >= 50) bg-yellow-900/50 text-yellow-300
|
||||
@else bg-red-900/50 text-red-300
|
||||
@endif">
|
||||
{{ round($game->aggregated_rating) }}/100
|
||||
</span>
|
||||
@if($game->aggregated_rating_count)
|
||||
<span class="text-slate-600 text-xs">({{ $game->aggregated_rating_count }} {{ Str::plural('review', $game->aggregated_rating_count) }})</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if($game->age_rating_pegi || $game->age_rating_esrb)
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-slate-500 w-36 flex-shrink-0">Age Ratings</span>
|
||||
<div class="flex gap-2">
|
||||
@if($game->age_rating_pegi)
|
||||
<span class="px-2 py-0.5 bg-[#16161d] border border-white/10 rounded text-xs font-bold text-slate-300">PEGI {{ $game->age_rating_pegi }}</span>
|
||||
@endif
|
||||
@if($game->age_rating_esrb)
|
||||
<span class="px-2 py-0.5 bg-[#16161d] border border-white/10 rounded text-xs font-bold text-slate-300">ESRB {{ $game->age_rating_esrb }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Websites --}}
|
||||
@php
|
||||
$importantCategories = [1, 13, 17, 16];
|
||||
$featuredSites = $game->websites->whereIn('category', $importantCategories)->sortBy(fn($w) => array_search($w->category, $importantCategories));
|
||||
@endphp
|
||||
@if($featuredSites->isNotEmpty())
|
||||
<div class="flex flex-wrap gap-2 mt-4">
|
||||
@foreach($featuredSites as $site)
|
||||
<a href="{{ $site->url }}" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-[#16161d] border border-white/10 hover:border-purple-500/50 rounded-lg text-xs text-slate-300 hover:text-white transition-all">
|
||||
@if($site->category === 13)
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor"><path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.187.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.029 4.524 4.524s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.606 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.455-.397.957-1.497 1.41-2.455 1.012H7.54zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.252 0-2.265-1.014-2.265-2.265z"/></svg>
|
||||
@elseif($site->category === 17)
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0zm5.61 17.26c-.2.2-.52.2-.72 0l-4.89-4.89-4.89 4.89c-.2.2-.52.2-.72 0-.2-.2-.2-.52 0-.72l4.89-4.89-4.89-4.89c-.2-.2-.2-.52 0-.72.2-.2.52-.2.72 0l4.89 4.89 4.89-4.89c.2-.2.52-.2.72 0 .2.2.2.52 0 .72l-4.89 4.89 4.89 4.89c.2.2.2.52 0 .72z"/></svg>
|
||||
@elseif($site->category === 16)
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor"><path d="M0 0h24v24H0z" fill="none"/><path d="M21 6.5C21 4.6 19.5 3 17.5 3c-1.9 0-3.5 1.6-3.5 3.5C14 8.4 15.6 10 17.5 10c1.9 0 3.5-1.5 3.5-3.5zM6.5 3C4.6 3 3 4.6 3 6.5S4.6 10 6.5 10 10 8.4 10 6.5 8.4 3 6.5 3zM17.5 14c-1.9 0-3.5 1.6-3.5 3.5S15.6 21 17.5 21c1.9 0 3.5-1.5 3.5-3.5S19.4 14 17.5 14zm-11 0C4.6 14 3 15.6 3 17.5S4.6 21 6.5 21 10 19.4 10 17.5 8.4 14 6.5 14z"/></svg>
|
||||
@else
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
@endif
|
||||
{{ $site->label }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -351,52 +250,6 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Storyline (only if different from summary) --}}
|
||||
@if($game->storyline && $game->storyline !== $game->summary)
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-white mb-3">Storyline</h2>
|
||||
<p class="text-slate-400 leading-relaxed max-w-3xl">{{ $game->storyline }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Trailers --}}
|
||||
@if($game->videos->isNotEmpty())
|
||||
<div class="mt-10 pt-8 border-t border-white/5"
|
||||
x-data="{
|
||||
activeVideo: '{{ $game->videos->first()->video_id }}',
|
||||
activeName: {{ json_encode($game->videos->first()->name ?? 'Trailer') }},
|
||||
}">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Videos</h2>
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="aspect-video rounded-xl overflow-hidden bg-black">
|
||||
<iframe
|
||||
:src="'https://www.youtube.com/embed/' + activeVideo + '?rel=0'"
|
||||
class="w-full h-full"
|
||||
loading="lazy"
|
||||
allowfullscreen
|
||||
title="Game video"
|
||||
></iframe>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500 mt-2" x-text="activeName"></p>
|
||||
</div>
|
||||
@if($game->videos->count() > 1)
|
||||
<div class="flex lg:flex-col gap-2 overflow-x-auto lg:overflow-y-auto lg:max-h-72 lg:w-48 flex-shrink-0">
|
||||
@foreach($game->videos as $video)
|
||||
<button @click="activeVideo = '{{ $video->video_id }}'; activeName = {{ json_encode($video->name ?? 'Video') }}"
|
||||
class="flex-shrink-0 lg:flex-shrink rounded-lg overflow-hidden border-2 transition-all w-32 lg:w-full aspect-video bg-black"
|
||||
:class="activeVideo === '{{ $video->video_id }}' ? 'border-purple-500' : 'border-transparent hover:border-white/20'">
|
||||
<img src="https://img.youtube.com/vi/{{ $video->video_id }}/mqdefault.jpg"
|
||||
alt="{{ $video->name ?? 'Video' }}"
|
||||
class="w-full h-full object-cover">
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Screenshots --}}
|
||||
@if($game->screenshots->isNotEmpty())
|
||||
<div class="mt-10 pt-8 border-t border-white/5"
|
||||
@@ -415,7 +268,7 @@
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
@foreach($game->screenshots as $i => $shot)
|
||||
<button @click="openAt({{ $i }})" class="block w-full rounded-lg overflow-hidden bg-[#16161d] hover:ring-1 hover:ring-purple-500 transition-all">
|
||||
<img src="{{ $shot->thumb_url ?? $shot->url }}" alt="{{ $game->title }} — screenshot {{ $loop->iteration }}, {{ $game->genres->first()?->name ?? 'gameplay' }} scene" class="w-full aspect-video object-cover">
|
||||
<img src="{{ $shot->thumb_url ?? $shot->url }}" alt="Screenshot" class="w-full aspect-video object-cover">
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@@ -450,7 +303,7 @@
|
||||
</button>
|
||||
|
||||
{{-- Full-size image --}}
|
||||
<img :src="screenshots[current]?.url" :alt="`{{ addslashes($game->title) }} screenshot ` + (current + 1)" class="max-h-[85vh] max-w-[90vw] rounded-lg shadow-2xl object-contain">
|
||||
<img :src="screenshots[current]?.url" alt="Screenshot" class="max-h-[85vh] max-w-[90vw] rounded-lg shadow-2xl object-contain">
|
||||
|
||||
{{-- Next --}}
|
||||
<button @click="next()" class="absolute right-4 top-1/2 -translate-y-1/2 text-white/70 hover:text-white transition-colors p-2">
|
||||
@@ -462,127 +315,6 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Comments --}}
|
||||
@php
|
||||
$commentsData = $comments->map(fn($c) => [
|
||||
'id' => $c->id,
|
||||
'body' => $c->body,
|
||||
'user' => ['name' => $c->user->name, 'slug' => $c->user->slug],
|
||||
'user_id' => $c->user_id,
|
||||
'created_at' => $c->created_at->diffForHumans(),
|
||||
'replies' => $c->replies->map(fn($r) => [
|
||||
'id' => $r->id,
|
||||
'body' => $r->body,
|
||||
'user' => ['name' => $r->user->name, 'slug' => $r->user->slug],
|
||||
'user_id' => $r->user_id,
|
||||
'created_at' => $r->created_at->diffForHumans(),
|
||||
])->values()->all(),
|
||||
])->values()->all();
|
||||
@endphp
|
||||
<div class="mt-10 pt-8 border-t border-white/5" id="comments"
|
||||
x-data="commentsWidget({{ Js::from($commentsData) }}, '{{ route('games.comments.store', $game) }}', {{ auth()->id() ?? 'null' }})">
|
||||
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Discussion</h2>
|
||||
<div class="max-w-3xl">
|
||||
|
||||
{{-- New comment form (auth only) --}}
|
||||
@auth
|
||||
<div class="mb-6 bg-[#16161d] border border-white/10 rounded-lg p-4">
|
||||
<textarea x-model="body" rows="3"
|
||||
placeholder="Share your thoughts…"
|
||||
class="w-full bg-[#0f0f13] border border-white/10 rounded-lg px-4 py-3 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 resize-none transition mb-3"></textarea>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-600" x-text="body.length + ' / 1000'"></span>
|
||||
<button @click="submitComment()"
|
||||
:disabled="!body.trim() || submitting"
|
||||
class="px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-semibold rounded-lg transition-colors">
|
||||
Post Comment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="mb-6 bg-[#16161d] border border-white/10 rounded-lg p-4 text-center">
|
||||
<p class="text-slate-400 text-sm">
|
||||
<a href="{{ route('login') }}" class="text-purple-400 hover:text-purple-300 transition-colors">Log in</a>
|
||||
to join the discussion.
|
||||
</p>
|
||||
</div>
|
||||
@endauth
|
||||
|
||||
{{-- Comment list --}}
|
||||
<div class="space-y-4">
|
||||
<template x-if="comments.length === 0">
|
||||
<p class="text-slate-500 text-sm">No comments yet. Be the first!</p>
|
||||
</template>
|
||||
<template x-for="comment in comments" :key="comment.id">
|
||||
<div class="bg-[#16161d] rounded-lg p-4 border border-white/5">
|
||||
{{-- Comment header --}}
|
||||
<div class="flex items-start justify-between gap-2 mb-2">
|
||||
<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 flex-shrink-0"
|
||||
x-text="comment.user.name.charAt(0).toUpperCase()"></div>
|
||||
<span class="text-sm font-medium text-slate-300" x-text="comment.user.slug || comment.user.name"></span>
|
||||
<span class="text-xs text-slate-600" x-text="comment.created_at"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@auth
|
||||
<button x-show="comment.user_id === authUserId"
|
||||
@click="deleteComment(comment)"
|
||||
class="text-xs text-red-500 hover:text-red-400 transition-colors">Delete</button>
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
{{-- Body --}}
|
||||
<p class="text-slate-400 text-sm leading-relaxed mb-3" x-text="comment.body"></p>
|
||||
|
||||
{{-- Replies --}}
|
||||
<div class="ml-6 space-y-3 mb-3">
|
||||
<template x-for="reply in comment.replies" :key="reply.id">
|
||||
<div class="bg-[#0f0f13] rounded-lg p-3 border border-white/5">
|
||||
<div class="flex items-start justify-between gap-2 mb-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-5 h-5 rounded-full bg-purple-800 flex items-center justify-center text-xs font-bold text-white flex-shrink-0"
|
||||
x-text="reply.user.name.charAt(0).toUpperCase()"></div>
|
||||
<span class="text-xs font-medium text-slate-300" x-text="reply.user.slug || reply.user.name"></span>
|
||||
<span class="text-xs text-slate-600" x-text="reply.created_at"></span>
|
||||
</div>
|
||||
@auth
|
||||
<button x-show="reply.user_id === authUserId"
|
||||
@click="deleteReply(reply, comment)"
|
||||
class="text-xs text-red-500 hover:text-red-400 transition-colors">Delete</button>
|
||||
@endauth
|
||||
</div>
|
||||
<p class="text-slate-400 text-xs leading-relaxed" x-text="reply.body"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- Reply toggle --}}
|
||||
@auth
|
||||
<div>
|
||||
<button @click="comment.showReply = !comment.showReply"
|
||||
class="text-xs text-slate-500 hover:text-purple-400 transition-colors">
|
||||
<span x-text="comment.showReply ? 'Cancel' : 'Reply'"></span>
|
||||
</button>
|
||||
<div x-show="comment.showReply" x-transition class="mt-2">
|
||||
<textarea x-model="comment.replyBody" rows="2"
|
||||
placeholder="Write a reply…"
|
||||
class="w-full bg-[#0f0f13] border border-white/10 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 resize-none transition mb-2"></textarea>
|
||||
<button @click="submitReply(comment)"
|
||||
:disabled="!comment.replyBody?.trim()"
|
||||
class="px-3 py-1.5 bg-purple-600 hover:bg-purple-700 disabled:opacity-40 disabled:cursor-not-allowed text-white text-xs font-semibold rounded-lg transition-colors">
|
||||
Post Reply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endauth
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Reviews --}}
|
||||
<div class="mt-10 pt-8 border-t border-white/5" id="reviews">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Reviews</h2>
|
||||
@@ -597,7 +329,7 @@
|
||||
<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->slug ?? $review->user->name }}</span>
|
||||
<span class="text-sm font-medium text-slate-300">{{ $review->user->username ?? $review->user->name }}</span>
|
||||
@if($review->has_spoilers)
|
||||
<span class="px-1.5 py-0.5 text-xs bg-yellow-900/40 text-yellow-400 border border-yellow-700/30 rounded">Spoilers</span>
|
||||
@endif
|
||||
@@ -630,7 +362,7 @@
|
||||
{{-- Write / Edit Review Form --}}
|
||||
@auth
|
||||
<div x-data="reviewForm(
|
||||
'{{ route('reviews.store', ['game' => $game->slug]) }}',
|
||||
'{{ route('reviews.store', $game->slug) }}',
|
||||
{{ $userReview ? $userReview->id : 'null' }},
|
||||
{{ $userReview ? json_encode($userReview->body) : 'null' }},
|
||||
{{ $userReview ? ($userReview->rating ?? 'null') : 'null' }},
|
||||
@@ -643,7 +375,7 @@
|
||||
<div x-show="!reviewId || editing">
|
||||
<textarea x-model="body" rows="5" placeholder="Share your thoughts (minimum 50 characters)…"
|
||||
class="w-full bg-[#0f0f13] border border-white/10 rounded-lg px-4 py-3 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 resize-none transition"></textarea>
|
||||
<p class="text-xs text-slate-500 mt-1 mb-3" x-text="body.length + ' | minimum 50 characters'"></p>
|
||||
<p class="text-xs text-slate-500 mt-1 mb-3" x-text="body.length + ' / 50 min'"></p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -688,46 +420,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($similarGames->isNotEmpty())
|
||||
<div class="mt-10 pt-8 border-t border-white/5">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">
|
||||
@if($game->similarGames->isNotEmpty())
|
||||
Similar Games
|
||||
@else
|
||||
You Might Also Like
|
||||
@endif
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
@foreach($similarGames as $similar)
|
||||
<x-game-card :game="$similar" />
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@@context": "https://schema.org",
|
||||
"@@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{"@@type": "ListItem", "position": 1, "name": "Home", "item": "{{ url('/') }}"},
|
||||
{"@@type": "ListItem", "position": 2, "name": "Games", "item": "{{ route('games.index') }}"},
|
||||
{"@@type": "ListItem", "position": 3, "name": "{{ addslashes($game->title) }}", "item": "{{ route('games.show', $game) }}"}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@@context": "https://schema.org",
|
||||
"@@type": "VideoGame",
|
||||
"@context": "https://schema.org",
|
||||
"@type": "VideoGame",
|
||||
"name": {{ json_encode($game->title) }},
|
||||
"description": {{ json_encode($game->summary ?? '') }},
|
||||
"url": {{ json_encode(route('games.show', $game)) }},
|
||||
"image": {{ json_encode($game->cover_url ?? '') }},
|
||||
"datePublished": {{ json_encode($game->release_date?->toDateString() ?? '') }}
|
||||
"url": {{ json_encode(route('games.show', $game)) }}
|
||||
@if($game->genres->isNotEmpty())
|
||||
,"genre": {{ json_encode($game->genres->pluck('name')->values()) }}
|
||||
@endif
|
||||
@@ -736,32 +438,13 @@
|
||||
@endif
|
||||
@if($game->rating_count > 0)
|
||||
,"aggregateRating": {
|
||||
"@@type": "AggregateRating",
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": {{ $game->rating_avg }},
|
||||
"ratingCount": {{ $game->rating_count }},
|
||||
"bestRating": 10,
|
||||
"worstRating": 1
|
||||
}
|
||||
@endif
|
||||
@if($game->reviews->isNotEmpty())
|
||||
,"review": [
|
||||
@foreach($game->reviews->take(5) as $r)
|
||||
@if($r->rating)
|
||||
{
|
||||
"@@type": "Review",
|
||||
"author": {"@@type": "Person", "name": "{{ addslashes($r->user->name ?? 'Anonymous') }}"},
|
||||
"reviewRating": {"@@type": "Rating", "ratingValue": "{{ $r->rating }}", "bestRating": "10", "worstRating": "1"},
|
||||
"reviewBody": "{{ addslashes(Str::limit(strip_tags($r->body), 200)) }}",
|
||||
"datePublished": "{{ $r->created_at->toDateString() }}"
|
||||
}{{ !$loop->last ? ',' : '' }}
|
||||
@endif
|
||||
@endforeach
|
||||
]
|
||||
@endif
|
||||
@php $developer = $game->companies->where('pivot.role', 'developer')->first(); @endphp
|
||||
@if($developer)
|
||||
,"author": {"@@type": "Organization", "name": {{ json_encode($developer->name) }}}
|
||||
@endif
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
@@ -912,82 +595,6 @@ function deleteReview(id) {
|
||||
}).then(r => { if (r.ok) window.location.reload(); });
|
||||
}
|
||||
|
||||
function commentsWidget(initialComments, storeUrl, authUserId) {
|
||||
return {
|
||||
comments: initialComments.map(c => ({ ...c, showReply: false, replyBody: '' })),
|
||||
body: '',
|
||||
submitting: false,
|
||||
authUserId,
|
||||
|
||||
async submitComment() {
|
||||
if (!this.body.trim() || this.submitting) return;
|
||||
this.submitting = true;
|
||||
const res = await fetch(storeUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ body: this.body }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const comment = await res.json();
|
||||
this.comments.unshift({ ...comment, replies: [], showReply: false, replyBody: '' });
|
||||
this.body = '';
|
||||
}
|
||||
this.submitting = false;
|
||||
},
|
||||
|
||||
async submitReply(parentComment) {
|
||||
if (!parentComment.replyBody?.trim()) return;
|
||||
const res = await fetch(storeUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ body: parentComment.replyBody, parent_id: parentComment.id }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const reply = await res.json();
|
||||
parentComment.replies.push(reply);
|
||||
parentComment.showReply = false;
|
||||
parentComment.replyBody = '';
|
||||
}
|
||||
},
|
||||
|
||||
async deleteComment(comment) {
|
||||
if (!confirm('Delete this comment?')) return;
|
||||
const res = await fetch(`/comments/${comment.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
this.comments = this.comments.filter(c => c.id !== comment.id);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteReply(reply, parentComment) {
|
||||
if (!confirm('Delete this reply?')) return;
|
||||
const res = await fetch(`/comments/${reply.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
parentComment.replies = parentComment.replies.filter(r => r.id !== reply.id);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function addToListWidget(gameId, lists) {
|
||||
return {
|
||||
gameId,
|
||||
@@ -995,11 +602,11 @@ function addToListWidget(gameId, lists) {
|
||||
open: false,
|
||||
added: {},
|
||||
|
||||
async toggleList(listSlug) {
|
||||
const isAdded = this.added[listSlug];
|
||||
async toggleList(listId) {
|
||||
const isAdded = this.added[listId];
|
||||
const url = isAdded
|
||||
? `/lists/${listSlug}/games/${this.gameId}`
|
||||
: `/lists/${listSlug}/games`;
|
||||
? `/lists/${listId}/games/${this.gameId}`
|
||||
: `/lists/${listId}/games`;
|
||||
const method = isAdded ? 'DELETE' : 'POST';
|
||||
|
||||
const res = await fetch(url, {
|
||||
@@ -1013,7 +620,7 @@ function addToListWidget(gameId, lists) {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
this.added[listSlug] = !isAdded;
|
||||
this.added[listId] = !isAdded;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Top Rated Video Games of All Time — Community Rankings | LoreKeepers')
|
||||
@section('description', 'Discover the highest-rated video games of all time, ranked by LoreKeepers community ratings. Filter by genre and platform to find the best games to play.')
|
||||
@section('canonical', route('games.top-rated'))
|
||||
@section('og_description', 'Discover the highest-rated video games of all time, ranked by LoreKeepers community ratings. Filter by genre and platform to find the best games to play.')
|
||||
@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 LoreKeepers.</p>
|
||||
<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">
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'LoreKeepers — Track, Rate & Discover Video Games You Love')
|
||||
@section('canonical', route('home'))
|
||||
@section('description', 'LoreKeepers lets you track, rate, and review every game you play. Build your game library, discover top-rated games, and connect with other players.')
|
||||
@section('og_title', 'LoreKeepers — Track, Rate & Discover Video Games You Love')
|
||||
@section('og_description', 'Track, rate and review every game you play. Build your game library, discover top-rated games, and connect with other players on LoreKeepers.')
|
||||
@section('title', 'GameLog — Track Every Game You Play')
|
||||
|
||||
@section('content')
|
||||
|
||||
|
||||
@@ -4,23 +4,19 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>@yield('title', 'LoreKeepers — Track Every Game You Play')</title>
|
||||
<meta name="description" content="@yield('description', 'LoreKeepers lets you track every game you play, rate your favorites, and discover what to play next.')">
|
||||
<title>@yield('title', 'GameLog — Track Every Game You Play')</title>
|
||||
<meta name="description" content="@yield('description', 'GameLog lets you track every game you play, rate your favorites, and discover what to play next.')">
|
||||
@hasSection('canonical')
|
||||
<link rel="canonical" href="@yield('canonical')">
|
||||
@endif
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="@yield('og_title', 'LoreKeepers — Track, Rate & Discover Video Games')">
|
||||
<meta property="og:title" content="@yield('og_title', 'GameLog — Track, Rate & Discover Video Games')">
|
||||
<meta property="og:description" content="@yield('og_description', 'Track every game you play. Rate, review, and discover new games.')">
|
||||
<meta property="og:image" content="@yield('og_image', asset('images/og-default.jpg'))">
|
||||
<meta property="og:type" content="@yield('og_type', 'website')">
|
||||
<meta property="og:url" content="@yield('og_url', url()->current())">
|
||||
<meta property="og:site_name" content="LoreKeepers">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="@yield('title', 'LoreKeepers — Track Every Game You Play')">
|
||||
<meta name="twitter:description" content="@yield('description', 'LoreKeepers lets you track every game you play, rate your favorites, and discover what to play next.')">
|
||||
<meta name="twitter:image" content="@yield('og_image', asset('images/og-default.jpg'))">
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
@@ -28,7 +24,6 @@
|
||||
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@stack('styles')
|
||||
@stack('head_links')
|
||||
</head>
|
||||
<body class="h-full bg-[#0f0f13] text-slate-200 antialiased font-sans">
|
||||
|
||||
@@ -40,16 +35,12 @@
|
||||
<!-- 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">
|
||||
LoreKeepers
|
||||
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>
|
||||
<a href="{{ route('lists.community') }}" class="text-sm text-slate-400 hover:text-slate-200 transition-colors">Lists</a>
|
||||
@auth
|
||||
<a href="{{ route('activity.index') }}" class="text-sm text-slate-400 hover:text-slate-200 transition-colors">Activity</a>
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -105,19 +96,6 @@
|
||||
<!-- Right: Auth -->
|
||||
<div class="flex items-center gap-3">
|
||||
@auth
|
||||
{{-- Notification bell --}}
|
||||
@php $unreadCount = auth()->user()->notifications()->whereNull('read_at')->count(); @endphp
|
||||
<a href="{{ route('notifications.index') }}" class="relative text-slate-400 hover:text-slate-200 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
|
||||
</svg>
|
||||
@if($unreadCount > 0)
|
||||
<span class="absolute -top-1 -right-1 w-4 h-4 bg-purple-600 rounded-full text-[10px] font-bold text-white flex items-center justify-center">
|
||||
{{ $unreadCount > 9 ? '9+' : $unreadCount }}
|
||||
</span>
|
||||
@endif
|
||||
</a>
|
||||
<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)
|
||||
@@ -127,20 +105,19 @@
|
||||
{{ strtoupper(substr(auth()->user()->name, 0, 1)) }}
|
||||
</div>
|
||||
@endif
|
||||
<span class="hidden sm:block">{{ auth()->user()->slug ?? auth()->user()->name }}</span>
|
||||
<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()->slug)
|
||||
<a href="{{ route('users.show', auth()->user()->slug) }}" 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()->slug) }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">My Library</a>
|
||||
@if(auth()->user()->username)
|
||||
<a href="{{ route('users.show', auth()->user()->username) }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">Profile</a>
|
||||
<a href="{{ route('users.library', auth()->user()->username) }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">My Library</a>
|
||||
<a href="{{ route('lists.index') }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">My Lists</a>
|
||||
@endif
|
||||
<a href="{{ route('profile.edit') }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">Settings</a>
|
||||
<a href="{{ route('settings.api-keys') }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">API Keys</a>
|
||||
<div class="border-t border-white/5"></div>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
@@ -183,14 +160,12 @@
|
||||
<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') }} LoreKeepers. Track every game you play.
|
||||
© {{ 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>
|
||||
<a href="{{ route('lists.community') }}" class="hover:text-slate-300 transition-colors">Lists</a>
|
||||
<a href="{{ route('api.docs') }}" class="hover:text-slate-300 transition-colors">Developers</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,7 +208,7 @@
|
||||
|
||||
goToSearch() {
|
||||
if (this.query.trim()) {
|
||||
window.location.href = `/games/search?q=${encodeURIComponent(this.query.trim())}`;
|
||||
window.location.href = `/games?q=${encodeURIComponent(this.query.trim())}`;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
@php use Illuminate\Support\Str; @endphp
|
||||
|
||||
@section('title', 'Community Lists — LoreKeepers')
|
||||
@section('description', 'Browse public game lists created by the LoreKeepers community.')
|
||||
|
||||
@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">Community Lists</h1>
|
||||
<p class="text-sm text-slate-500 mt-1">Public game lists from LoreKeepers members</p>
|
||||
</div>
|
||||
@auth
|
||||
<a href="{{ route('lists.index') }}"
|
||||
class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm font-semibold rounded-lg transition-colors">
|
||||
My Lists
|
||||
</a>
|
||||
@endauth
|
||||
</div>
|
||||
|
||||
@if($lists->isEmpty())
|
||||
<div class="text-center py-20">
|
||||
<svg class="mx-auto w-12 h-12 text-slate-700 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||
</svg>
|
||||
<p class="text-slate-500 mb-2">No public lists yet.</p>
|
||||
@guest
|
||||
<p class="text-slate-600 text-sm"><a href="{{ route('register') }}" class="text-purple-400 hover:text-purple-300 transition-colors">Sign up</a> and create the first one!</p>
|
||||
@endguest
|
||||
</div>
|
||||
@else
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
@foreach($lists as $list)
|
||||
<a href="{{ route('lists.show', $list) }}"
|
||||
class="group block bg-[#16161d] border border-white/5 rounded-lg p-5 hover:border-purple-500/50 transition-all">
|
||||
<h2 class="font-semibold text-white group-hover:text-purple-300 transition-colors line-clamp-1 mb-1">
|
||||
{{ $list->title }}
|
||||
</h2>
|
||||
@if($list->description)
|
||||
<p class="text-sm text-slate-500 line-clamp-2 mb-3">{{ $list->description }}</p>
|
||||
@else
|
||||
<div class="mb-3"></div>
|
||||
@endif
|
||||
<div class="flex items-center justify-between mt-auto">
|
||||
<span class="text-xs text-slate-600">
|
||||
{{ $list->list_games_count }} {{ Str::plural('game', $list->list_games_count) }}
|
||||
</span>
|
||||
@if($list->user)
|
||||
<span class="text-xs text-purple-400 truncate max-w-[8rem]">
|
||||
{{ $list->user->username }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
{{ $lists->links() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,8 +1,7 @@
|
||||
@extends('layouts.app')
|
||||
@php use Illuminate\Support\Str; @endphp
|
||||
|
||||
@section('title', 'My Lists — LoreKeepers')
|
||||
@section('robots', 'noindex,follow')
|
||||
@section('title', 'My Lists — GameLog')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@extends('layouts.app')
|
||||
@php use Illuminate\Support\Str; @endphp
|
||||
|
||||
@section('title', $list->title . ' — LoreKeepers')
|
||||
@section('title', $list->title . ' — GameLog')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
||||
@@ -10,8 +10,8 @@
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<a href="{{ route('users.show', ['user' => $list->user->slug]) }}" class="text-sm text-slate-500 hover:text-slate-300 transition-colors">
|
||||
{{ $list->user->slug ?? $list->user->name }}
|
||||
<a href="{{ route('users.show', $list->user->username) }}" class="text-sm text-slate-500 hover:text-slate-300 transition-colors">
|
||||
{{ $list->user->username ?? $list->user->name }}
|
||||
</a>
|
||||
@if(!$list->is_public)
|
||||
<span class="text-xs text-slate-500 bg-[#16161d] px-2 py-0.5 rounded border border-white/10">Private</span>
|
||||
@@ -56,7 +56,7 @@
|
||||
<x-game-card :game="$entry->game" />
|
||||
@auth
|
||||
@if(auth()->id() === $list->user_id)
|
||||
<button onclick="removeGameFromList('{{ $list->slug }}', '{{ $entry->game->slug }}', this)"
|
||||
<button onclick="removeGameFromList({{ $list->id }}, {{ $entry->game->id }}, this)"
|
||||
class="absolute top-2 right-2 w-6 h-6 bg-red-600 hover:bg-red-700 text-white rounded-full text-xs font-bold opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center leading-none">
|
||||
×
|
||||
</button>
|
||||
@@ -71,9 +71,9 @@
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function removeGameFromList(listSlug, gameSlug, btn) {
|
||||
function removeGameFromList(listId, gameId, btn) {
|
||||
if (!confirm('Remove from list?')) return;
|
||||
fetch(`/lists/${listSlug}/games/${gameSlug}`, {
|
||||
fetch(`/lists/${listId}/games/${gameId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Notifications — LoreKeepers')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
||||
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Notifications</h1>
|
||||
|
||||
@if($notifications->isEmpty())
|
||||
<div class="text-center py-20">
|
||||
<div class="w-16 h-16 rounded-full bg-[#16161d] flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 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 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-slate-300 mb-2">No notifications yet</h3>
|
||||
<p class="text-slate-500">You'll see notifications here when someone follows you.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach($notifications as $notification)
|
||||
<div class="bg-[#16161d] border border-white/5 rounded-xl p-4 flex items-center gap-4 {{ is_null($notification->read_at) ? 'border-purple-500/20 bg-purple-900/10' : '' }}">
|
||||
@if($notification->type === 'new_follower')
|
||||
@php $data = $notification->data ?? []; @endphp
|
||||
<div class="w-10 h-10 rounded-full bg-purple-700 flex items-center justify-center text-sm font-bold text-white flex-shrink-0">
|
||||
{{ strtoupper(substr($data['follower_name'] ?? '?', 0, 1)) }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-slate-300">
|
||||
@if(!empty($data['follower_slug']))
|
||||
<a href="{{ route('users.show', $data['follower_slug']) }}"
|
||||
class="font-semibold text-white hover:text-purple-300 transition-colors">
|
||||
{{ $data['follower_name'] ?? $data['follower_slug'] }}
|
||||
</a>
|
||||
@else
|
||||
<span class="font-semibold text-white">Someone</span>
|
||||
@endif
|
||||
started following you
|
||||
</p>
|
||||
<p class="text-xs text-slate-600 mt-0.5">{{ $notification->created_at?->diffForHumans() }}</p>
|
||||
</div>
|
||||
@if(!empty($data['follower_slug']))
|
||||
<a href="{{ route('users.show', $data['follower_slug']) }}"
|
||||
class="text-xs text-purple-400 hover:text-purple-300 transition-colors flex-shrink-0">
|
||||
View profile →
|
||||
</a>
|
||||
@endif
|
||||
@else
|
||||
<div class="w-10 h-10 rounded-full bg-slate-700 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-slate-400">{{ $notification->type }}</p>
|
||||
<p class="text-xs text-slate-600 mt-0.5">{{ $notification->created_at?->diffForHumans() }}</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,87 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'API Keys — LoreKeepers')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-3xl mx-auto px-4 py-10">
|
||||
|
||||
<h1 class="text-2xl font-bold text-white mb-1">API Keys</h1>
|
||||
<p class="text-slate-400 text-sm mb-8">Use API keys to access the <a href="{{ route('api.docs') }}" class="text-purple-400 hover:text-purple-300 underline">LoreKeepers API</a>. Keep them secret — treat them like passwords.</p>
|
||||
|
||||
@if(session('new_api_key'))
|
||||
<div class="mb-8 p-4 bg-purple-900/30 border border-purple-500/40 rounded-xl" x-data="{ copied: false }">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<svg class="w-4 h-4 text-yellow-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="text-yellow-300 text-sm font-semibold">This key will only be shown once. Copy it now.</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-3">
|
||||
<code class="flex-1 bg-[#0f0f13] border border-white/10 rounded-lg px-4 py-2.5 text-sm text-green-300 font-mono break-all select-all">{{ session('new_api_key') }}</code>
|
||||
<button @click="navigator.clipboard.writeText('{{ session('new_api_key') }}'); copied = true; setTimeout(() => copied = false, 2000)"
|
||||
class="flex-shrink-0 px-3 py-2.5 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors font-medium">
|
||||
<span x-show="!copied">Copy</span>
|
||||
<span x-show="copied" style="display:none">Copied!</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="bg-[#16161d] border border-white/5 rounded-xl mb-8 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-white/5">
|
||||
<h2 class="text-sm font-semibold text-slate-200">Your API Keys</h2>
|
||||
</div>
|
||||
@forelse($keys as $key)
|
||||
<div class="px-5 py-4 flex items-center justify-between gap-4 @if(!$loop->last) border-b border-white/5 @endif">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-white">{{ $key->name }}</div>
|
||||
<div class="text-xs text-slate-500 mt-0.5">
|
||||
Created {{ $key->created_at->format('M j, Y') }}
|
||||
@if($key->last_used_at)
|
||||
· Last used {{ $key->last_used_at->diffForHumans() }}
|
||||
@else
|
||||
· Never used
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('settings.api-keys.destroy', $key) }}" onsubmit="return confirm('Delete this API key? This cannot be undone.')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-xs px-3 py-1.5 bg-red-900/40 hover:bg-red-800/60 border border-red-700/40 text-red-400 hover:text-red-300 rounded-lg transition-colors">
|
||||
Revoke
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@empty
|
||||
<div class="px-5 py-8 text-center text-slate-500 text-sm">
|
||||
No API keys yet. Create one below.
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="bg-[#16161d] border border-white/5 rounded-xl overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-white/5">
|
||||
<h2 class="text-sm font-semibold text-slate-200">Create New Key</h2>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('settings.api-keys.store') }}" class="px-5 py-4">
|
||||
@csrf
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<label for="name" class="block text-xs text-slate-400 mb-1.5">Key Label</label>
|
||||
<input type="text" id="name" name="name" value="{{ old('name') }}"
|
||||
placeholder="e.g. My App, Personal Script"
|
||||
class="w-full bg-[#0f0f13] border border-white/10 rounded-lg px-3 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"
|
||||
maxlength="50">
|
||||
@error('name')
|
||||
<p class="text-red-400 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors font-medium whitespace-nowrap">
|
||||
Generate Key
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,7 +1,6 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', ($user->slug ?? $user->name) . "'s Library — LoreKeepers")
|
||||
@section('robots', 'noindex,follow')
|
||||
@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">
|
||||
@@ -9,8 +8,8 @@
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">
|
||||
<a href="{{ route('users.show', $user->slug) }}" class="hover:text-purple-400 transition-colors">
|
||||
{{ $user->slug ?? $user->name }}
|
||||
<a href="{{ route('users.show', $user->username) }}" class="hover:text-purple-400 transition-colors">
|
||||
{{ $user->username ?? $user->name }}
|
||||
</a>'s Library
|
||||
</h1>
|
||||
</div>
|
||||
@@ -22,7 +21,7 @@
|
||||
$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->slug, 'status' => $tabStatus ?: null]) }}"
|
||||
<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'
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', ($user->slug ?? $user->name) . ' — LoreKeepers')
|
||||
@section('canonical', route('users.show', $user->slug))
|
||||
@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">
|
||||
@@ -17,8 +16,8 @@
|
||||
@endif
|
||||
|
||||
<div class="flex-1">
|
||||
<h1 class="text-2xl font-bold text-white">{{ $user->slug ?? $user->name }}</h1>
|
||||
@if($user->slug && $user->name !== $user->slug)
|
||||
<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)
|
||||
@@ -46,62 +45,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-6">
|
||||
{{-- Follower / Following counts --}}
|
||||
<div class="flex gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-lg font-bold text-white">{{ $user->followers()->count() }}</div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wide">Followers</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-bold text-white">{{ $user->following()->count() }}</div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wide">Following</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
@auth
|
||||
@if(auth()->id() !== $user->id)
|
||||
<div x-data="{
|
||||
following: {{ auth()->user()->isFollowing($user) ? 'true' : 'false' }},
|
||||
async toggle() {
|
||||
const method = this.following ? 'DELETE' : 'POST';
|
||||
const res = await fetch('{{ route('users.follow', $user->slug) }}', {
|
||||
method,
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
this.following = d.following;
|
||||
}
|
||||
}
|
||||
}">
|
||||
<button @click="toggle()"
|
||||
:class="following
|
||||
? 'bg-white/10 text-slate-300 hover:bg-red-900/30 hover:text-red-400 border border-white/10'
|
||||
: 'bg-purple-600 hover:bg-purple-700 text-white'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors">
|
||||
<span x-text="following ? 'Following' : 'Follow'"></span>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
@endauth
|
||||
<a href="{{ route('users.library', $user->slug) }}"
|
||||
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>
|
||||
@auth
|
||||
@if(auth()->id() !== $user->id)
|
||||
<a href="{{ route('users.compare', $user->slug) }}"
|
||||
class="px-4 py-2 border border-purple-500/40 hover:border-purple-500 text-purple-300 hover:text-white text-sm font-medium rounded-lg transition-colors">
|
||||
Compare Libraries
|
||||
</a>
|
||||
@endif
|
||||
@endauth
|
||||
</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>
|
||||
|
||||
@@ -125,7 +73,7 @@
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white">Recent Library</h2>
|
||||
<a href="{{ route('users.library', $user->slug) }}" class="text-sm text-purple-400 hover:text-purple-300 transition-colors">
|
||||
<a href="{{ route('users.library', $user->username) }}" class="text-sm text-purple-400 hover:text-purple-300 transition-colors">
|
||||
View all →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\V1\GameApiController;
|
||||
use App\Http\Controllers\Api\V1\MetaApiController;
|
||||
use App\Http\Controllers\Api\V1\UserApiController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('api/v1')->middleware(['api.key', 'throttle:api'])->group(function () {
|
||||
// Games
|
||||
Route::get('/games', [GameApiController::class, 'index']);
|
||||
Route::get('/games/search', [GameApiController::class, 'search']);
|
||||
Route::get('/games/top-rated', [GameApiController::class, 'topRated']);
|
||||
Route::get('/games/new-releases', [GameApiController::class, 'newReleases']);
|
||||
Route::get('/games/{game:slug}', [GameApiController::class, 'show']);
|
||||
|
||||
// Meta
|
||||
Route::get('/genres', [MetaApiController::class, 'genres']);
|
||||
Route::get('/platforms', [MetaApiController::class, 'platforms']);
|
||||
|
||||
// Users (public profiles)
|
||||
Route::get('/users/{user:slug}', [UserApiController::class, 'show']);
|
||||
Route::get('/users/{user:slug}/library', [UserApiController::class, 'library']);
|
||||
});
|
||||
@@ -2,10 +2,7 @@
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
Schedule::command('sitemap:generate')->weekly();
|
||||
|
||||
+6
-58
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\CompareController;
|
||||
use App\Http\Controllers\GameCommentController;
|
||||
use App\Http\Controllers\GameController;
|
||||
use App\Http\Controllers\HomeController;
|
||||
use App\Http\Controllers\LibraryController;
|
||||
@@ -20,13 +18,8 @@ Route::get('/new-releases', [GameController::class, 'newReleases'])->name('games
|
||||
Route::get('/games/{game:slug}', [GameController::class, 'show'])->name('games.show');
|
||||
|
||||
// User profiles (public)
|
||||
Route::get('/users/{user:slug}', [UserController::class, 'show'])->name('users.show');
|
||||
Route::get('/users/{user:slug}/library', [UserController::class, 'library'])->name('users.library');
|
||||
|
||||
// Compare (auth required)
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/users/{user:slug}/compare', [CompareController::class, 'show'])->name('users.compare');
|
||||
});
|
||||
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 () {
|
||||
@@ -42,57 +35,13 @@ Route::middleware('auth')->group(function () {
|
||||
Route::delete('/reviews/{review}', [\App\Http\Controllers\ReviewController::class, 'destroy'])->name('reviews.destroy');
|
||||
});
|
||||
|
||||
// Comments (auth required)
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::post('/games/{game:slug}/comments', [GameCommentController::class, 'store'])->name('games.comments.store');
|
||||
Route::delete('/comments/{comment}', [GameCommentController::class, 'destroy'])->name('comments.destroy');
|
||||
});
|
||||
|
||||
// Lists
|
||||
Route::get('/community/lists', [\App\Http\Controllers\ListController::class, 'community'])->name('lists.community');
|
||||
Route::get('/lists', [\App\Http\Controllers\ListController::class, 'index'])->name('lists.index')->middleware('auth');
|
||||
Route::post('/lists', [\App\Http\Controllers\ListController::class, 'store'])->name('lists.store')->middleware('auth');
|
||||
Route::get('/lists/{list:slug}', [\App\Http\Controllers\ListController::class, 'show'])->name('lists.show');
|
||||
Route::delete('/lists/{list:slug}', [\App\Http\Controllers\ListController::class, 'destroy'])->name('lists.destroy')->middleware('auth');
|
||||
Route::post('/lists/{list:slug}/games', [\App\Http\Controllers\ListController::class, 'addGame'])->name('lists.add-game')->middleware('auth');
|
||||
Route::delete('/lists/{list:slug}/games/{game}', [\App\Http\Controllers\ListController::class, 'removeGame'])->name('lists.remove-game')->middleware('auth');
|
||||
|
||||
// Follow system
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::post('/users/{user:slug}/follow', [\App\Http\Controllers\FollowController::class, 'store'])->name('users.follow');
|
||||
Route::delete('/users/{user:slug}/follow', [\App\Http\Controllers\FollowController::class, 'destroy'])->name('users.unfollow');
|
||||
});
|
||||
|
||||
// Activity feed
|
||||
Route::get('/activity', [\App\Http\Controllers\ActivityController::class, 'index'])->name('activity.index')->middleware('auth');
|
||||
Route::get('/activity/global', [\App\Http\Controllers\ActivityController::class, 'global'])->name('activity.global');
|
||||
|
||||
// Notifications
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/notifications', [\App\Http\Controllers\NotificationController::class, 'index'])->name('notifications.index');
|
||||
Route::post('/notifications/{notification}/read', [\App\Http\Controllers\NotificationController::class, 'markRead'])->name('notifications.read');
|
||||
});
|
||||
|
||||
// Sitemap
|
||||
Route::get('/sitemap.xml', function () {
|
||||
$path = public_path('sitemap.xml');
|
||||
if (!file_exists($path)) {
|
||||
abort(404);
|
||||
}
|
||||
return response()->file($path, ['Content-Type' => 'application/xml']);
|
||||
})->name('sitemap');
|
||||
|
||||
// API Key Management
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/settings/api-keys', [\App\Http\Controllers\ApiKeyController::class, 'index'])->name('settings.api-keys');
|
||||
Route::post('/settings/api-keys', [\App\Http\Controllers\ApiKeyController::class, 'store'])->name('settings.api-keys.store');
|
||||
Route::delete('/settings/api-keys/{apiKey}', [\App\Http\Controllers\ApiKeyController::class, 'destroy'])->name('settings.api-keys.destroy');
|
||||
});
|
||||
|
||||
// API Docs (public)
|
||||
Route::get('/api/docs', function () {
|
||||
return view('api.docs');
|
||||
})->name('api.docs');
|
||||
Route::get('/lists/{list}', [\App\Http\Controllers\ListController::class, 'show'])->name('lists.show');
|
||||
Route::delete('/lists/{list}', [\App\Http\Controllers\ListController::class, 'destroy'])->name('lists.destroy')->middleware('auth');
|
||||
Route::post('/lists/{list}/games', [\App\Http\Controllers\ListController::class, 'addGame'])->name('lists.add-game')->middleware('auth');
|
||||
Route::delete('/lists/{list}/games/{game}', [\App\Http\Controllers\ListController::class, 'removeGame'])->name('lists.remove-game')->middleware('auth');
|
||||
|
||||
// Dashboard (Breeze default, keep for now)
|
||||
Route::get('/dashboard', function () {
|
||||
@@ -107,4 +56,3 @@ Route::middleware('auth')->group(function () {
|
||||
});
|
||||
|
||||
require __DIR__.'/auth.php';
|
||||
require __DIR__.'/api.php';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!data/
|
||||
!.gitignore
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
Reference in New Issue
Block a user