Compare commits

Author SHA1 Message Date
Doar Mihai f6b2377b0f fixed bug 2026-08-09 15:49:16 +03:00
MarkandClaude Sonnet 4.6 bb6323b141 Fix SEO audit issues: brand rename cleanup, sitemap HTTPS, structured data, titles, descriptions, canonicals
- Replace all "GameLog" brand references with "LoreKeepers" (og:site_name, twitter meta, top-rated, community lists)
- Force HTTPS in sitemap generation; update .env.example APP_URL to https://lorekeepers.games
- Enhance VideoGame JSON-LD on game pages: add image, datePublished, author fields
- Improve title tags across all main pages (keyword-rich, include year on game pages)
- Add/update meta descriptions and og:description on all public pages
- Add canonical tag and og_title/og_description to homepage
- Add rel=prev/next pagination links on /games via head_links stack
- Add @stack('head_links') to layout head for child view injection
- Smarter description truncation on game pages (last sentence boundary)
- Improve screenshot alt text with genre context

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:49:27 +03:00
Doar Mihai ea3e376507 minor fixes 2026-06-01 16:47:18 +03:00
MarkandClaude Sonnet 4.6 2a724c0ae2 Expand IGDB data: collections, game modes, themes, perspectives, videos, websites, age ratings, similar games; add backfill command
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:24:05 +03:00
MarkandClaude Sonnet 4.6 0eab3e8dbf Add public community lists browse page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:43:45 +03:00
MarkandClaude Sonnet 4.6 a583a13da1 Add public API v1 with API key auth, rate limiting, and docs page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:29:18 +03:00
Doar Mihai 68d01448d2 a fix 2026-05-31 22:57:36 +03:00
Doar Mihai ea3e226398 backfill command and minor fixes 2026-05-31 16:14:40 +03:00
Doar Mihai b99ad721da homepage fix 2026-05-31 14:39:00 +03:00
Doar Mihai 3ba13e5521 up to date 2026-05-31 14:26:22 +03:00
Doar Mihai cf29dc141a minor fixes regarding name 2026-05-31 14:19:47 +03:00
MarkandClaude Sonnet 4.6 cdf17f92d7 SEO fixes: robots.txt, canonicals, noindex, og tags, breadcrumbs, similar games, review schema
- robots.txt: proper Disallow rules + sitemap URL
- Layout: robots meta tag, fix canonical syntax, og:site_name, full Twitter Card tags
- Game pages: canonical URLs, og:type website, breadcrumb nav + JSON-LD schema
- Screenshot alt text with game title and index
- VideoGame JSON-LD: review array with rating/author/body
- Similar Games section on game detail (by genre, ordered by rating)
- browse/index: noindex on paginated or multi-filter pages
- search results: noindex + improved title
- User library and lists: noindex
- Unique meta descriptions on browse, top-rated, new-releases pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 03:05:08 +03:00
Doar Mihai bdb4457e77 changed username to slug 2026-05-30 23:47:32 +03:00
MarkandClaude Sonnet 4.6 ece3070676 Ensure PHP 8.3 compatibility
Pin symfony/yaml to ^7.4 — v8.x requires PHP >=8.4.1 which is
incompatible with our PHP 8.3 requirement. All packages that depend
on symfony/yaml accept ^7.0, so this is a safe downgrade.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 23:41:13 +03:00
Doar Mihai afc49488a9 fixes 2026-05-30 18:56:17 +03:00
Doar Mihai 17a2211952 minor fixes 2026-05-30 17:18:19 +03:00
Doar Mihai 5a14dccdc0 multiple fixes 2026-05-30 13:26:41 +03:00
Doar Mihai 5d45797fa9 game page bug fix 2026-05-30 12:38:40 +03:00
MarkandClaude Sonnet 4.6 14047dfe37 Add friend library comparison and game comments features
- CompareController: side-by-side library diff with stats (common games, rating diff)
- GameCommentController + GameCommentPolicy: threaded comments with replies, auth-gated
- GameComment model with top-level scope and self-referential replies relation
- Migration: game_comments table with parent_id for threading
- games/show: Discussion section with Alpine.js (post, reply, delete)
- compare/show: tabbed view (in common, they have, you have) with game cards
- users/show: "Compare Libraries" button visible to other authenticated users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:04:24 +03:00
Mark 3083071a6c Add follow system, activity feed, notifications, sitemap, caching, Scout search 2026-05-30 11:23:24 +03:00
86 changed files with 5509 additions and 265 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ APP_NAME=Laravel
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost APP_URL=https://lorekeepers.games
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
+68
View File
@@ -0,0 +1,68 @@
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
@@ -0,0 +1,95 @@
<?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.");
}
}
@@ -0,0 +1,103 @@
<?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;
}
}
@@ -0,0 +1,95 @@
<?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);
}
}
@@ -0,0 +1,56 @@
<?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;
}
}
+23 -95
View File
@@ -2,32 +2,43 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Company; use App\Console\Commands\Concerns\UpsertsGames;
use App\Models\Game;
use App\Models\GameScreenshot;
use App\Models\Genre;
use App\Models\Platform;
use App\Services\IgdbService; use App\Services\IgdbService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class ImportGamesCommand extends Command class ImportGamesCommand extends Command
{ {
protected $signature = 'games:import {--limit=100} {--offset=0} {--popular} {--new}'; use UpsertsGames;
protected $signature = 'games:import {--limit=100} {--offset=0} {--popular} {--new} {--year=} {--platform=}';
protected $description = 'Import games from IGDB into the local database'; protected $description = 'Import games from IGDB into the local database';
public function handle(IgdbService $igdb): int public function handle(IgdbService $igdb): int
{ {
$limit = (int) $this->option('limit'); $limit = (int) $this->option('limit');
$offset = (int) $this->option('offset'); $offset = (int) $this->option('offset');
$year = $this->option('year') ? (int) $this->option('year') : null;
$platform = $this->option('platform') ? (int) $this->option('platform') : null;
$this->info("Fetching {$limit} games from IGDB (offset: {$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})…");
try { try {
if ($this->option('new')) { $results = match (true) {
$results = $igdb->getNewReleases($limit); $year !== null => $igdb->getGamesByYear($year, $limit, $offset),
} else { $platform !== null => $igdb->getGamesByPlatform($platform, $limit, $offset),
$results = $igdb->getPopularGames($limit, $offset); $this->option('new') => $igdb->getNewReleases($limit),
} default => $igdb->getPopularGames($limit, $offset),
};
} catch (\Exception $e) { } catch (\Exception $e) {
$this->error('IGDB request failed: ' . $e->getMessage()); $this->error('IGDB request failed: ' . $e->getMessage());
return self::FAILURE; return self::FAILURE;
@@ -60,87 +71,4 @@ class ImportGamesCommand extends Command
return self::SUCCESS; return self::SUCCESS;
} }
private function upsertGame(array $data, IgdbService $igdb): void
{
$igdbId = $data['id'];
$title = $data['name'] ?? 'Unknown';
$releaseDate = isset($data['first_release_date'])
? date('Y-m-d', $data['first_release_date'])
: null;
$releaseYear = $releaseDate ? substr($releaseDate, 0, 4) : null;
$coverUrl = null;
$coverThumbUrl = null;
if (!empty($data['cover']['image_id'])) {
$coverUrl = $igdb::coverUrl($data['cover']['image_id'], 'cover_big');
$coverThumbUrl = $igdb::coverUrl($data['cover']['image_id'], 'cover_small');
}
$game = Game::updateOrCreate(
['igdb_id' => $igdbId],
[
'title' => $title,
'summary' => $data['summary'] ?? null,
'cover_url' => $coverUrl,
'cover_thumb_url'=> $coverThumbUrl,
'release_date' => $releaseDate,
'release_year' => $releaseYear,
'status' => 'published',
]
);
// Genres
if (!empty($data['genres'])) {
$genreIds = [];
foreach ($data['genres'] as $g) {
$genre = Genre::firstOrCreate(
['igdb_id' => $g['id']],
['name' => $g['name'], 'slug' => \Str::slug($g['name'])]
);
$genreIds[] = $genre->id;
}
$game->genres()->sync($genreIds);
}
// Platforms
if (!empty($data['platforms'])) {
$platformIds = [];
foreach ($data['platforms'] as $p) {
$platform = Platform::firstOrCreate(
['igdb_id' => $p['id']],
['name' => $p['name'], 'slug' => \Str::slug($p['name'])]
);
$platformIds[] = $platform->id;
}
$game->platforms()->sync($platformIds);
}
// Companies (involved_companies)
if (!empty($data['involved_companies'])) {
$companyPivot = [];
foreach ($data['involved_companies'] as $ic) {
if (empty($ic['company']['name'])) continue;
$company = Company::firstOrCreate(
['igdb_id' => $ic['company']['id']],
['name' => $ic['company']['name'], 'slug' => \Str::slug($ic['company']['name'])]
);
$role = $ic['developer'] ? 'developer' : ($ic['publisher'] ? 'publisher' : 'other');
$companyPivot[$company->id] = ['role' => $role];
}
$game->companies()->sync($companyPivot);
}
// Screenshots
if (!empty($data['screenshots'])) {
GameScreenshot::where('game_id', $game->id)->delete();
foreach ($data['screenshots'] as $i => $shot) {
GameScreenshot::create([
'game_id' => $game->id,
'url' => $igdb::screenshotUrl($shot['image_id'], 'screenshot_big'),
'thumb_url' => $igdb::screenshotUrl($shot['image_id'], 'screenshot_med'),
'order' => $i,
]);
}
}
}
} }
@@ -0,0 +1,30 @@
<?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'));
}
}
@@ -0,0 +1,146 @@
<?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(),
],
]);
}
}
@@ -0,0 +1,25 @@
<?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']),
]);
}
}
@@ -0,0 +1,58 @@
<?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(),
],
]);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?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,6 +9,7 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules; use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Illuminate\View\View; use Illuminate\View\View;
@@ -36,8 +37,16 @@ class RegisteredUserController extends Controller
'password' => ['required', 'confirmed', Rules\Password::defaults()], '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([ $user = User::create([
'name' => $request->name, 'name' => $request->name,
'slug' => $slug,
'email' => $request->email, 'email' => $request->email,
'password' => Hash::make($request->password), 'password' => Hash::make($request->password),
]); ]);
@@ -0,0 +1,62 @@
<?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'));
}
}
+1 -1
View File
@@ -4,5 +4,5 @@ namespace App\Http\Controllers;
abstract class Controller abstract class Controller
{ {
// use \Illuminate\Foundation\Auth\Access\AuthorizesRequests;
} }
+49
View File
@@ -0,0 +1,49 @@
<?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(),
]);
}
}
@@ -0,0 +1,49 @@
<?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]);
}
}
+50 -15
View File
@@ -3,9 +3,11 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Game; use App\Models\Game;
use App\Models\GameComment;
use App\Models\Genre; use App\Models\Genre;
use App\Models\Platform; use App\Models\Platform;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class GameController extends Controller class GameController extends Controller
{ {
@@ -37,12 +39,19 @@ class GameController extends Controller
$genres = Genre::orderBy('name')->get(); $genres = Genre::orderBy('name')->get();
$platforms = Platform::orderBy('name')->get(); $platforms = Platform::orderBy('name')->get();
return view('games.index', compact('games', 'genres', 'platforms')); $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'));
} }
public function show(Game $game) public function show(Game $game)
{ {
$game->load(['genres', 'platforms', 'companies', 'screenshots', 'reviews.user']); $game->load([
'genres', 'platforms', 'companies', 'screenshots', 'reviews.user',
'collection', 'gameModes', 'themes', 'playerPerspectives', 'videos', 'websites',
'similarGames' => fn($q) => $q->published()->with(['genres'])->limit(6),
]);
$userGame = null; $userGame = null;
$userReview = null; $userReview = null;
@@ -50,10 +59,34 @@ class GameController extends Controller
if (auth()->check()) { if (auth()->check()) {
$userGame = $game->userGames()->where('user_id', auth()->id())->first(); $userGame = $game->userGames()->where('user_id', auth()->id())->first();
$userReview = $game->reviews()->where('user_id', auth()->id())->first(); $userReview = $game->reviews()->where('user_id', auth()->id())->first();
$userLists = auth()->user()->lists()->get(['id', 'title']); $userLists = auth()->user()->lists()->get(['id', 'slug', 'title']);
} }
return view('games.show', compact('game', 'userGame', 'userReview', 'userLists')); $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');
} }
public function search(Request $request) public function search(Request $request)
@@ -62,15 +95,11 @@ class GameController extends Controller
$games = collect(); $games = collect();
if (strlen($q) >= 2) { if (strlen($q) >= 2) {
$games = Game::published() $games = Game::search($q)->get()->filter(fn($g) => $g->status === 'published');
->where('title', 'like', "%{$q}%")
->orderByDesc('rating_count')
->limit(20)
->get();
} }
if ($request->expectsJson()) { if ($request->expectsJson()) {
return response()->json($games->map(fn($g) => [ return response()->json($games->take(6)->map(fn($g) => [
'id' => $g->id, 'id' => $g->id,
'title' => $g->title, 'title' => $g->title,
'slug' => $g->slug, 'slug' => $g->slug,
@@ -78,7 +107,7 @@ class GameController extends Controller
'release_year' => $g->release_year, 'release_year' => $g->release_year,
'rating_avg' => $g->rating_avg, 'rating_avg' => $g->rating_avg,
'url' => route('games.show', $g), 'url' => route('games.show', $g),
])); ])->values());
} }
return view('games.search', compact('games', 'q')); return view('games.search', compact('games', 'q'));
@@ -86,24 +115,30 @@ class GameController extends Controller
public function topRated(Request $request) public function topRated(Request $request)
{ {
$games = Game::published() $page = $request->get('page', 1);
$games = Cache::remember("games.top_rated.{$page}", 300, fn() =>
Game::published()
->where('rating_count', '>=', 1) ->where('rating_count', '>=', 1)
->orderByDesc('rating_avg') ->orderByDesc('rating_avg')
->orderByDesc('rating_count') ->orderByDesc('rating_count')
->paginate(24) ->paginate(24)
->withQueryString(); ->withQueryString()
);
return view('games.top-rated', compact('games')); return view('games.top-rated', compact('games'));
} }
public function newReleases(Request $request) public function newReleases(Request $request)
{ {
$games = Game::published() $page = $request->get('page', 1);
$games = Cache::remember("games.new_releases.{$page}", 300, fn() =>
Game::published()
->whereNotNull('release_date') ->whereNotNull('release_date')
->where('release_date', '<=', now()) ->where('release_date', '<=', now())
->orderByDesc('release_date') ->orderByDesc('release_date')
->paginate(24) ->paginate(24)
->withQueryString(); ->withQueryString()
);
return view('games.new-releases', compact('games')); return view('games.new-releases', compact('games'));
} }
+2 -1
View File
@@ -22,6 +22,7 @@ class HomeController extends Controller
->limit(8) ->limit(8)
->get(); ->get();
return view('home', compact('topRated', 'newReleases')); return response()->view('home', compact('topRated', 'newReleases'))
->header('Cache-Control', 'no-store, private');
} }
} }
@@ -8,11 +8,6 @@ use Illuminate\Http\Request;
class LibraryController extends Controller class LibraryController extends Controller
{ {
public function __construct()
{
$this->middleware('auth');
}
public function store(Request $request) public function store(Request $request)
{ {
$request->validate([ $request->validate([
+9 -2
View File
@@ -10,11 +10,18 @@ use Illuminate\Support\Str;
class ListController extends Controller class ListController extends Controller
{ {
public function __construct() public function community()
{ {
$this->middleware('auth')->except('show'); $lists = UserList::where('is_public', true)
->with('user')
->withCount('listGames')
->latest()
->paginate(24);
return view('lists.community', compact('lists'));
} }
public function index() public function index()
{ {
$lists = auth()->user()->lists()->withCount('listGames')->latest()->get(); $lists = auth()->user()->lists()->withCount('listGames')->latest()->get();
@@ -0,0 +1,27 @@
<?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]);
}
}
@@ -0,0 +1,31 @@
<?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);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?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)),
]);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?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);
}
}
+65 -3
View File
@@ -3,24 +3,29 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Cache;
use Laravel\Scout\Searchable;
use Spatie\Sluggable\HasSlug; use Spatie\Sluggable\HasSlug;
use Spatie\Sluggable\SlugOptions; use Spatie\Sluggable\SlugOptions;
class Game extends Model class Game extends Model
{ {
use HasSlug; use HasSlug, Searchable;
protected $fillable = [ protected $fillable = [
'igdb_id', 'slug', 'title', 'summary', 'cover_url', 'cover_thumb_url', 'igdb_id', 'slug', 'title', 'summary', 'storyline', 'cover_url', 'cover_thumb_url',
'release_date', 'release_year', 'rating_avg', 'rating_count', 'review_count', 'release_date', 'release_year', 'rating_avg', 'rating_count', 'review_count',
'library_count', 'status', 'library_count', 'status', 'collection_id', 'age_rating_pegi', 'age_rating_esrb',
'aggregated_rating', 'aggregated_rating_count', 'similar_game_igdb_ids',
]; ];
protected $casts = [ protected $casts = [
'release_date' => 'date', 'release_date' => 'date',
'rating_avg' => 'decimal:2', 'rating_avg' => 'decimal:2',
'similar_game_igdb_ids' => 'array',
]; ];
public function getSlugOptions(): SlugOptions public function getSlugOptions(): SlugOptions
@@ -30,6 +35,11 @@ class Game extends Model
public function getRouteKeyName(): string { return 'slug'; } public function getRouteKeyName(): string { return 'slug'; }
public function collection(): BelongsTo
{
return $this->belongsTo(Collection::class);
}
public function genres(): BelongsToMany public function genres(): BelongsToMany
{ {
return $this->belongsToMany(Genre::class, 'game_genre'); return $this->belongsToMany(Genre::class, 'game_genre');
@@ -60,6 +70,55 @@ class Game extends Model
return $this->hasMany(Review::class)->latest(); 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) public function scopePublished($query)
{ {
return $query->where('status', 'published'); return $query->where('status', 'published');
@@ -72,5 +131,8 @@ class Game extends Model
'rating_avg' => $stats->avg ? round($stats->avg, 2) : null, 'rating_avg' => $stats->avg ? round($stats->avg, 2) : null,
'rating_count' => $stats->cnt ?? 0, 'rating_count' => $stats->cnt ?? 0,
]); ]);
Cache::forget('home.top_rated');
Cache::forget('games.top_rated.1');
} }
} }
+19
View File
@@ -0,0 +1,19 @@
<?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'); }
}
+16
View File
@@ -0,0 +1,16 @@
<?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');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?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}";
}
}
+31
View File
@@ -0,0 +1,31 @@
<?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';
}
}
+25
View File
@@ -0,0 +1,25 @@
<?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);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?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');
}
}
+5
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Services\ActivityService;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -23,6 +24,10 @@ class Review extends Model
static::deleted(function (Review $review) { static::deleted(function (Review $review) {
$review->game->recalculateRating(); $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); } public function user(): BelongsTo { return $this->belongsTo(User::class); }
+16
View File
@@ -0,0 +1,16 @@
<?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');
}
}
+4 -2
View File
@@ -10,16 +10,18 @@ class User extends Authenticatable
{ {
use HasFactory, Notifiable; use HasFactory, Notifiable;
protected $fillable = ['name','username','email','password','avatar','bio','location','website','is_private','is_admin']; protected $fillable = ['name','slug','email','password','avatar','bio','location','website','is_private','is_admin'];
protected $hidden = ['password','remember_token']; protected $hidden = ['password','remember_token'];
protected $casts = ['email_verified_at'=>'datetime','password'=>'hashed','is_private'=>'boolean','is_admin'=>'boolean']; protected $casts = ['email_verified_at'=>'datetime','password'=>'hashed','is_private'=>'boolean','is_admin'=>'boolean'];
public function getRouteKeyName(): string { return 'username'; } public function getRouteKeyName(): string { return 'slug'; }
public function library(): HasMany { return $this->hasMany(UserGame::class); } public function library(): HasMany { return $this->hasMany(UserGame::class); }
public function reviews(): HasMany { return $this->hasMany(Review::class); } public function reviews(): HasMany { return $this->hasMany(Review::class); }
public function lists(): HasMany { return $this->hasMany(UserList::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 activities(): HasMany { return $this->hasMany(Activity::class)->latest(); }
public function notifications(): HasMany { return $this->hasMany(Notification::class)->latest('created_at'); }
public function following(): BelongsToMany public function following(): BelongsToMany
{ {
+16
View File
@@ -1,5 +1,6 @@
<?php <?php
namespace App\Models; namespace App\Models;
use App\Services\ActivityService;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -10,6 +11,21 @@ class UserGame extends Model
const STATUSES = ['playing','played','want_to_play','on_hold','dropped']; 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 user(): BelongsTo { return $this->belongsTo(User::class); }
public function game(): BelongsTo { return $this->belongsTo(Game::class); } public function game(): BelongsTo { return $this->belongsTo(Game::class); }
} }
+2
View File
@@ -11,6 +11,8 @@ class UserList extends Model
{ {
protected $fillable = ['user_id', 'title', 'slug', 'description', 'is_public']; protected $fillable = ['user_id', 'title', 'slug', 'description', 'is_public'];
public function getRouteKeyName(): string { return 'slug'; }
protected $casts = ['is_public' => 'boolean']; protected $casts = ['is_public' => 'boolean'];
protected static function boot(): void protected static function boot(): void
+14
View File
@@ -0,0 +1,14 @@
<?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;
}
}
+14
View File
@@ -0,0 +1,14 @@
<?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;
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ class ReviewPolicy
{ {
public function create(User $user, Game $game): bool public function create(User $user, Game $game): bool
{ {
return $user->library()->where('game_id', $game->id)->exists(); return true;
} }
public function update(User $user, Review $review): bool public function update(User $user, Review $review): bool
+12
View File
@@ -4,7 +4,10 @@ namespace App\Providers;
use App\Models\UserGame; use App\Models\UserGame;
use App\Policies\UserGamePolicy; use App\Policies\UserGamePolicy;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -25,5 +28,14 @@ class AppServiceProvider extends ServiceProvider
Gate::policy(UserGame::class, UserGamePolicy::class); Gate::policy(UserGame::class, UserGamePolicy::class);
Gate::policy(\App\Models\Review::class, \App\Policies\ReviewPolicy::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\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);
});
});
} }
} }
+15
View File
@@ -0,0 +1,15 @@
<?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);
}
}
+231 -24
View File
@@ -2,6 +2,14 @@
namespace App\Services; 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 GuzzleHttp\Client;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -13,6 +21,10 @@ class IgdbService
protected string $clientSecret; protected string $clientSecret;
protected string $baseUrl; 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() public function __construct()
{ {
$this->client = new Client(['timeout' => 10]); $this->client = new Client(['timeout' => 10]);
@@ -60,6 +72,27 @@ 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. * Search games by title.
*/ */
@@ -81,34 +114,33 @@ class IgdbService
public function getGame(int $igdbId): ?array public function getGame(int $igdbId): ?array
{ {
$results = $this->query('games', " $results = $this->query('games', "
fields id,name,slug,summary,cover.image_id,first_release_date, fields {$this->extendedFields()};
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}; where id = {$igdbId};
limit 1; limit 1;
"); ");
return $results[0] ?? null; 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). * Get popular games (by rating and rating count).
*/ */
public function getPopularGames(int $limit = 20, int $offset = 0): array public function getPopularGames(int $limit = 20, int $offset = 0): array
{ {
return $this->query('games', " return $this->query('games', "
fields id,name,slug,summary,cover.image_id,first_release_date, fields {$this->extendedFields()};
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; where rating != null & rating_count > 100 & cover != null;
sort rating_count desc; sort rating_count desc;
limit {$limit}; limit {$limit};
@@ -116,6 +148,20 @@ 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. * Get recently released games.
*/ */
@@ -125,20 +171,44 @@ class IgdbService
$now = now()->timestamp; $now = now()->timestamp;
return $this->query('games', " return $this->query('games', "
fields id,name,slug,summary,cover.image_id,first_release_date, fields {$this->extendedFields()};
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; where first_release_date >= {$timestamp} & first_release_date <= {$now} & cover != null;
sort first_release_date desc; sort first_release_date desc;
limit {$limit}; 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. * Get games by genre.
*/ */
@@ -155,6 +225,143 @@ 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. * Build a full cover image URL from an IGDB image_id.
*/ */
+3 -1
View File
@@ -12,7 +12,9 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// $middleware->alias([
'api.key' => \App\Http\Middleware\AuthenticateApiKey::class,
]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen( $exceptions->shouldRenderJsonWhen(
+2
View File
@@ -10,7 +10,9 @@
"guzzlehttp/guzzle": "^7.10", "guzzlehttp/guzzle": "^7.10",
"intervention/image": "^4.1", "intervention/image": "^4.1",
"laravel/framework": "^13.8", "laravel/framework": "^13.8",
"laravel/scout": "^11.2",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"spatie/laravel-sitemap": "^7.4",
"spatie/laravel-sluggable": "^4.0", "spatie/laravel-sluggable": "^4.0",
"symfony/yaml": "^7.4" "symfony/yaml": "^7.4"
}, },
Generated
+671 -7
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "090b1a1ba0d65aaa9d57fd54fdb4370e", "content-hash": "26bdf3db91d9eb34ccbe7666ad92c525",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -853,16 +853,16 @@
}, },
{ {
"name": "guzzlehttp/psr7", "name": "guzzlehttp/psr7",
"version": "2.10.3", "version": "2.10.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/guzzle/psr7.git", "url": "https://github.com/guzzle/psr7.git",
"reference": "7c1472269227dc6f18930bd903d7a88fe6c52130" "reference": "d2a1a094e396da8957e797489fddaf860c340cfc"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/7c1472269227dc6f18930bd903d7a88fe6c52130", "url": "https://api.github.com/repos/guzzle/psr7/zipball/d2a1a094e396da8957e797489fddaf860c340cfc",
"reference": "7c1472269227dc6f18930bd903d7a88fe6c52130", "reference": "d2a1a094e396da8957e797489fddaf860c340cfc",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -950,7 +950,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/guzzle/psr7/issues", "issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/2.10.3" "source": "https://github.com/guzzle/psr7/tree/2.10.4"
}, },
"funding": [ "funding": [
{ {
@@ -966,7 +966,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-27T11:48:20+00:00" "time": "2026-05-29T12:59:07+00:00"
}, },
{ {
"name": "guzzlehttp/uri-template", "name": "guzzlehttp/uri-template",
@@ -1481,6 +1481,86 @@
}, },
"time": "2026-05-19T00:47:18+00:00" "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", "name": "laravel/serializable-closure",
"version": "v2.0.13", "version": "v2.0.13",
@@ -2170,6 +2250,73 @@
], ],
"time": "2026-03-08T20:05:35+00:00" "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", "name": "monolog/monolog",
"version": "3.10.0", "version": "3.10.0",
@@ -2536,6 +2683,60 @@
}, },
"time": "2026-05-11T20:49:54+00:00" "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", "name": "nikic/php-parser",
"version": "v5.7.0", "version": "v5.7.0",
@@ -3445,6 +3646,276 @@
}, },
"time": "2025-12-14T04:43:48+00:00" "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", "name": "spatie/laravel-sluggable",
"version": "4.0.2", "version": "4.0.2",
@@ -3523,6 +3994,127 @@
], ],
"time": "2026-04-30T17:28:09+00:00" "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", "name": "symfony/clock",
"version": "v7.4.8", "version": "v7.4.8",
@@ -3839,6 +4431,78 @@
], ],
"time": "2026-04-13T15:52:40+00:00" "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", "name": "symfony/error-handler",
"version": "v7.4.8", "version": "v7.4.8",
+210
View File
@@ -0,0 +1,210 @@
<?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'),
],
];
@@ -0,0 +1,25 @@
<?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');
}
};
@@ -0,0 +1,25 @@
<?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');
}
};
@@ -0,0 +1,22 @@
<?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');
});
}
};
@@ -0,0 +1,25 @@
<?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');
}
};
@@ -0,0 +1,16 @@
<?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'); }
};
@@ -0,0 +1,16 @@
<?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'); }
};
@@ -0,0 +1,16 @@
<?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'); }
};
@@ -0,0 +1,16 @@
<?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'); }
};
@@ -0,0 +1,25 @@
<?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']);
});
}
};
@@ -0,0 +1,17 @@
<?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'); }
};
@@ -0,0 +1,16 @@
<?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'); }
};
@@ -0,0 +1,16 @@
<?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'); }
};
@@ -0,0 +1,30 @@
<?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');
}
};
@@ -0,0 +1,429 @@
# 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"`
---
@@ -0,0 +1,998 @@
# 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 &amp; 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
```
+17 -1
View File
@@ -1,2 +1,18 @@
User-agent: * User-agent: *
Disallow: 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
+114
View File
@@ -0,0 +1,114 @@
@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
+108
View File
@@ -0,0 +1,108 @@
@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 &amp; 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
+206
View File
@@ -0,0 +1,206 @@
@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"> class="group block bg-[#16161d] rounded-lg overflow-hidden hover:ring-1 hover:ring-purple-500 transition-all duration-200">
<!-- Cover --> <!-- Cover -->
<div class="aspect-[3/4] bg-[#0f0f13] overflow-hidden"> <div class="aspect-[3/4] bg-[#0f0f13] overflow-hidden">
@if($game->cover_thumb_url) @if($game->cover_url)
<img src="{{ $game->cover_thumb_url }}" <img src="{{ $game->cover_url }}"
alt="{{ $game->title }}" alt="{{ $game->title }}"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"> class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300">
@else @else
+16 -1
View File
@@ -1,6 +1,21 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Browse Games — GameLog') @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('content') @section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
+5 -2
View File
@@ -1,11 +1,14 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'New Releases — GameLog') @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('content') @section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10"> <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> <h1 class="text-2xl font-bold text-white mb-2">New Releases</h1>
<p class="text-slate-500 mb-8">The latest games in the GameLog database.</p> <p class="text-slate-500 mb-8">The latest games in the LoreKeepers database.</p>
@if($games->isEmpty()) @if($games->isEmpty())
<div class="text-center py-24 text-slate-500"> <div class="text-center py-24 text-slate-500">
+2 -1
View File
@@ -1,6 +1,7 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', $q ? "Search: {$q} — GameLog" : 'Search Games — GameLog') @section('title', $q ? "Search: {$q} — LoreKeepers" : 'Search Games — LoreKeepers')
@section('robots', 'noindex,follow')
@section('content') @section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
+421 -28
View File
@@ -1,14 +1,33 @@
@extends('layouts.app') @extends('layouts.app')
@php use Illuminate\Support\Str; @endphp @php use Illuminate\Support\Str; @endphp
@section('title', $game->title . ' — GameLog') @section('title', $game->title . ($game->release_year ? ' (' . $game->release_year . ')' : '') . ' — Reviews, Rating & Info | LoreKeepers')
@section('description', $game->summary ? Str::limit($game->summary, 155) : 'Track ' . $game->title . ' on GameLog.') @php
@section('og_title', $game->title . ' — GameLog') $metaDesc = $game->summary ?? '';
@section('og_description', $game->summary ? Str::limit($game->summary, 155) : 'Track ' . $game->title . ' on GameLog.') 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('og_image', $game->cover_url ?? asset('images/og-default.jpg')) @section('og_image', $game->cover_url ?? asset('images/og-default.jpg'))
@section('og_type', 'game') @section('og_type', 'website')
@section('canonical', route('games.show', $game))
@section('content') @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"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
{{-- Main Info --}} {{-- Main Info --}}
@@ -196,27 +215,28 @@
<div x-show="open" @click.outside="open = false" x-transition <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" class="mt-1 w-56 bg-[#16161d] border border-white/10 rounded-lg shadow-xl overflow-hidden z-10"
style="display:none;"> style="display:none;">
@if($userLists->isEmpty()) <template x-if="lists.length === 0">
<div class="px-4 py-3 text-sm text-slate-500"> <div class="px-4 py-3 text-sm text-slate-500">
No lists yet. No lists yet.
<a href="{{ route('lists.index') }}" class="text-purple-400 hover:text-purple-300">Create one</a> <a href="{{ route('lists.index') }}" class="text-purple-400 hover:text-purple-300">Create one</a>
</div> </div>
@else </template>
<template x-for="list in lists" :key="list.id"> <template x-for="list in lists" :key="list.slug">
<button type="button" @click="toggleList(list.id)" <button type="button" @click="toggleList(list.slug)"
class="w-full flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-white/5 transition-colors" class="w-full flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-white/5 transition-colors"
:class="added[list.id] ? 'text-purple-400' : 'text-slate-300'"> :class="added[list.slug] ? '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"> <svg x-show="added[list.slug]" 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"/> <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> </svg>
<span x-show="!added[list.id]" class="w-4 h-4 flex-shrink-0"></span> <span x-show="!added[list.slug]" class="w-4 h-4 flex-shrink-0"></span>
<span x-text="list.title" class="truncate"></span> <span x-text="list.title" class="truncate"></span>
</button> </button>
</template> </template>
<template x-if="lists.length > 0">
<div class="border-t border-white/5 px-4 py-2"> <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> <a href="{{ route('lists.index') }}" class="text-xs text-slate-500 hover:text-slate-300 transition-colors">Manage lists →</a>
</div> </div>
@endif </template>
</div> </div>
</div> </div>
@else @else
@@ -225,7 +245,7 @@
{{-- Companies --}} {{-- Companies --}}
@if($game->companies->isNotEmpty()) @if($game->companies->isNotEmpty())
<div class="text-sm text-slate-500 space-y-1"> <div class="text-sm text-slate-500 space-y-1 mb-4">
@php @php
$developers = $game->companies->where('pivot.role', 'developer'); $developers = $game->companies->where('pivot.role', 'developer');
$publishers = $game->companies->where('pivot.role', 'publisher'); $publishers = $game->companies->where('pivot.role', 'publisher');
@@ -239,6 +259,87 @@
</div> </div>
@endif @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>
</div> </div>
@@ -250,6 +351,52 @@
</div> </div>
@endif @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 --}} {{-- Screenshots --}}
@if($game->screenshots->isNotEmpty()) @if($game->screenshots->isNotEmpty())
<div class="mt-10 pt-8 border-t border-white/5" <div class="mt-10 pt-8 border-t border-white/5"
@@ -268,7 +415,7 @@
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3"> <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
@foreach($game->screenshots as $i => $shot) @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"> <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="Screenshot" class="w-full aspect-video object-cover"> <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">
</button> </button>
@endforeach @endforeach
</div> </div>
@@ -303,7 +450,7 @@
</button> </button>
{{-- Full-size image --}} {{-- Full-size image --}}
<img :src="screenshots[current]?.url" alt="Screenshot" class="max-h-[85vh] max-w-[90vw] rounded-lg shadow-2xl object-contain"> <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">
{{-- Next --}} {{-- 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"> <button @click="next()" class="absolute right-4 top-1/2 -translate-y-1/2 text-white/70 hover:text-white transition-colors p-2">
@@ -315,6 +462,127 @@
</div> </div>
@endif @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 --}} {{-- Reviews --}}
<div class="mt-10 pt-8 border-t border-white/5" id="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> <h2 class="text-lg font-semibold text-white mb-4">Reviews</h2>
@@ -329,7 +597,7 @@
<div class="w-7 h-7 rounded-full bg-purple-700 flex items-center justify-center text-xs font-bold text-white"> <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)) }} {{ strtoupper(substr($review->user->name, 0, 1)) }}
</div> </div>
<span class="text-sm font-medium text-slate-300">{{ $review->user->username ?? $review->user->name }}</span> <span class="text-sm font-medium text-slate-300">{{ $review->user->slug ?? $review->user->name }}</span>
@if($review->has_spoilers) @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> <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 @endif
@@ -362,7 +630,7 @@
{{-- Write / Edit Review Form --}} {{-- Write / Edit Review Form --}}
@auth @auth
<div x-data="reviewForm( <div x-data="reviewForm(
'{{ route('reviews.store', $game->slug) }}', '{{ route('reviews.store', ['game' => $game->slug]) }}',
{{ $userReview ? $userReview->id : 'null' }}, {{ $userReview ? $userReview->id : 'null' }},
{{ $userReview ? json_encode($userReview->body) : 'null' }}, {{ $userReview ? json_encode($userReview->body) : 'null' }},
{{ $userReview ? ($userReview->rating ?? 'null') : 'null' }}, {{ $userReview ? ($userReview->rating ?? 'null') : 'null' }},
@@ -375,7 +643,7 @@
<div x-show="!reviewId || editing"> <div x-show="!reviewId || editing">
<textarea x-model="body" rows="5" placeholder="Share your thoughts (minimum 50 characters)…" <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> 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 + ' / 50 min'"></p> <p class="text-xs text-slate-500 mt-1 mb-3" x-text="body.length + ' | minimum 50 characters'"></p>
<div class="flex flex-wrap items-center gap-4 mb-4"> <div class="flex flex-wrap items-center gap-4 mb-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -420,16 +688,46 @@
</div> </div>
</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> </div>
@push('scripts') @push('scripts')
<script type="application/ld+json"> <script type="application/ld+json">
{ {
"@context": "https://schema.org", "@@context": "https://schema.org",
"@type": "VideoGame", "@@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",
"name": {{ json_encode($game->title) }}, "name": {{ json_encode($game->title) }},
"description": {{ json_encode($game->summary ?? '') }}, "description": {{ json_encode($game->summary ?? '') }},
"url": {{ json_encode(route('games.show', $game)) }} "url": {{ json_encode(route('games.show', $game)) }},
"image": {{ json_encode($game->cover_url ?? '') }},
"datePublished": {{ json_encode($game->release_date?->toDateString() ?? '') }}
@if($game->genres->isNotEmpty()) @if($game->genres->isNotEmpty())
,"genre": {{ json_encode($game->genres->pluck('name')->values()) }} ,"genre": {{ json_encode($game->genres->pluck('name')->values()) }}
@endif @endif
@@ -438,13 +736,32 @@
@endif @endif
@if($game->rating_count > 0) @if($game->rating_count > 0)
,"aggregateRating": { ,"aggregateRating": {
"@type": "AggregateRating", "@@type": "AggregateRating",
"ratingValue": {{ $game->rating_avg }}, "ratingValue": {{ $game->rating_avg }},
"ratingCount": {{ $game->rating_count }}, "ratingCount": {{ $game->rating_count }},
"bestRating": 10, "bestRating": 10,
"worstRating": 1 "worstRating": 1
} }
@endif @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>
<script> <script>
@@ -595,6 +912,82 @@ function deleteReview(id) {
}).then(r => { if (r.ok) window.location.reload(); }); }).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) { function addToListWidget(gameId, lists) {
return { return {
gameId, gameId,
@@ -602,11 +995,11 @@ function addToListWidget(gameId, lists) {
open: false, open: false,
added: {}, added: {},
async toggleList(listId) { async toggleList(listSlug) {
const isAdded = this.added[listId]; const isAdded = this.added[listSlug];
const url = isAdded const url = isAdded
? `/lists/${listId}/games/${this.gameId}` ? `/lists/${listSlug}/games/${this.gameId}`
: `/lists/${listId}/games`; : `/lists/${listSlug}/games`;
const method = isAdded ? 'DELETE' : 'POST'; const method = isAdded ? 'DELETE' : 'POST';
const res = await fetch(url, { const res = await fetch(url, {
@@ -620,7 +1013,7 @@ function addToListWidget(gameId, lists) {
}); });
if (res.ok) { if (res.ok) {
this.added[listId] = !isAdded; this.added[listSlug] = !isAdded;
} }
}, },
}; };
+5 -2
View File
@@ -1,11 +1,14 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Top Rated Games — GameLog') @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('content') @section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10"> <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> <h1 class="text-2xl font-bold text-white mb-2">Top Rated</h1>
<p class="text-slate-500 mb-8">The highest-rated games on GameLog.</p> <p class="text-slate-500 mb-8">The highest-rated games on LoreKeepers.</p>
@if($games->isEmpty()) @if($games->isEmpty())
<div class="text-center py-24 text-slate-500"> <div class="text-center py-24 text-slate-500">
+5 -1
View File
@@ -1,6 +1,10 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'GameLog — Track Every Game You Play') @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('content') @section('content')
+35 -10
View File
@@ -4,19 +4,23 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', 'GameLog — Track Every Game You Play')</title> <title>@yield('title', 'LoreKeepers — 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.')"> <meta name="description" content="@yield('description', 'LoreKeepers lets you track every game you play, rate your favorites, and discover what to play next.')">
@hasSection('canonical') @hasSection('canonical')
<link rel="canonical" href="@yield('canonical')"> <link rel="canonical" href="@yield('canonical')">
@endif @endif
<!-- Open Graph --> <!-- Open Graph -->
<meta property="og:title" content="@yield('og_title', 'GameLog — Track, Rate & Discover Video Games')"> <meta property="og:title" content="@yield('og_title', 'LoreKeepers — 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: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:image" content="@yield('og_image', asset('images/og-default.jpg'))">
<meta property="og:type" content="@yield('og_type', 'website')"> <meta property="og:type" content="@yield('og_type', 'website')">
<meta property="og:url" content="@yield('og_url', url()->current())"> <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: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 --> <!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net"> <link rel="preconnect" href="https://fonts.bunny.net">
@@ -24,6 +28,7 @@
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
@stack('styles') @stack('styles')
@stack('head_links')
</head> </head>
<body class="h-full bg-[#0f0f13] text-slate-200 antialiased font-sans"> <body class="h-full bg-[#0f0f13] text-slate-200 antialiased font-sans">
@@ -35,12 +40,16 @@
<!-- Left: Logo + Nav Links --> <!-- Left: Logo + Nav Links -->
<div class="flex items-center gap-8"> <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"> <a href="{{ route('home') }}" class="text-xl font-bold text-purple-400 hover:text-purple-300 transition-colors">
GameLog LoreKeepers
</a> </a>
<div class="hidden md:flex items-center gap-6"> <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.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.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('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>
</div> </div>
@@ -96,6 +105,19 @@
<!-- Right: Auth --> <!-- Right: Auth -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@auth @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 }"> <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"> <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) @if(auth()->user()->avatar)
@@ -105,19 +127,20 @@
{{ strtoupper(substr(auth()->user()->name, 0, 1)) }} {{ strtoupper(substr(auth()->user()->name, 0, 1)) }}
</div> </div>
@endif @endif
<span class="hidden sm:block">{{ auth()->user()->username ?? auth()->user()->name }}</span> <span class="hidden sm:block">{{ auth()->user()->slug ?? auth()->user()->name }}</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg> </svg>
</button> </button>
<div x-show="open" @click.outside="open = false" x-transition <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"> class="absolute right-0 mt-2 w-48 bg-[#16161d] border border-white/10 rounded-lg shadow-xl overflow-hidden z-50">
@if(auth()->user()->username) @if(auth()->user()->slug)
<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.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()->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('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>
<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> <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 @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('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> <div class="border-t border-white/5"></div>
<form method="POST" action="{{ route('logout') }}"> <form method="POST" action="{{ route('logout') }}">
@csrf @csrf
@@ -160,12 +183,14 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <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="flex flex-col md:flex-row items-center justify-between gap-4">
<div class="text-slate-500 text-sm"> <div class="text-slate-500 text-sm">
© {{ date('Y') }} GameLog. Track every game you play. © {{ date('Y') }} LoreKeepers. Track every game you play.
</div> </div>
<div class="flex items-center gap-6 text-sm text-slate-500"> <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.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.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('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> </div>
</div> </div>
@@ -208,7 +233,7 @@
goToSearch() { goToSearch() {
if (this.query.trim()) { if (this.query.trim()) {
window.location.href = `/games?q=${encodeURIComponent(this.query.trim())}`; window.location.href = `/games/search?q=${encodeURIComponent(this.query.trim())}`;
} }
}, },
}; };
+66
View File
@@ -0,0 +1,66 @@
@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
+2 -1
View File
@@ -1,7 +1,8 @@
@extends('layouts.app') @extends('layouts.app')
@php use Illuminate\Support\Str; @endphp @php use Illuminate\Support\Str; @endphp
@section('title', 'My Lists — GameLog') @section('title', 'My Lists — LoreKeepers')
@section('robots', 'noindex,follow')
@section('content') @section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
+6 -6
View File
@@ -1,7 +1,7 @@
@extends('layouts.app') @extends('layouts.app')
@php use Illuminate\Support\Str; @endphp @php use Illuminate\Support\Str; @endphp
@section('title', $list->title . ' — GameLog') @section('title', $list->title . ' — LoreKeepers')
@section('content') @section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10"> <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 class="flex items-start justify-between mb-2">
<div> <div>
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1">
<a href="{{ route('users.show', $list->user->username) }}" class="text-sm text-slate-500 hover:text-slate-300 transition-colors"> <a href="{{ route('users.show', ['user' => $list->user->slug]) }}" class="text-sm text-slate-500 hover:text-slate-300 transition-colors">
{{ $list->user->username ?? $list->user->name }} {{ $list->user->slug ?? $list->user->name }}
</a> </a>
@if(!$list->is_public) @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> <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" /> <x-game-card :game="$entry->game" />
@auth @auth
@if(auth()->id() === $list->user_id) @if(auth()->id() === $list->user_id)
<button onclick="removeGameFromList({{ $list->id }}, {{ $entry->game->id }}, this)" <button onclick="removeGameFromList('{{ $list->slug }}', '{{ $entry->game->slug }}', 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"> 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> </button>
@@ -71,9 +71,9 @@
@push('scripts') @push('scripts')
<script> <script>
function removeGameFromList(listId, gameId, btn) { function removeGameFromList(listSlug, gameSlug, btn) {
if (!confirm('Remove from list?')) return; if (!confirm('Remove from list?')) return;
fetch(`/lists/${listId}/games/${gameId}`, { fetch(`/lists/${listSlug}/games/${gameSlug}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
@@ -0,0 +1,66 @@
@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
@@ -0,0 +1,87 @@
@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
+5 -4
View File
@@ -1,6 +1,7 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', ($user->username ?? $user->name) . "'s Library — GameLog") @section('title', ($user->slug ?? $user->name) . "'s Library — LoreKeepers")
@section('robots', 'noindex,follow')
@section('content') @section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
@@ -8,8 +9,8 @@
<div class="flex items-center justify-between mb-8"> <div class="flex items-center justify-between mb-8">
<div> <div>
<h1 class="text-2xl font-bold text-white"> <h1 class="text-2xl font-bold text-white">
<a href="{{ route('users.show', $user->username) }}" class="hover:text-purple-400 transition-colors"> <a href="{{ route('users.show', $user->slug) }}" class="hover:text-purple-400 transition-colors">
{{ $user->username ?? $user->name }} {{ $user->slug ?? $user->name }}
</a>'s Library </a>'s Library
</h1> </h1>
</div> </div>
@@ -21,7 +22,7 @@
$tabs = ['' => 'All', 'playing' => 'Playing', 'played' => 'Played', 'want_to_play' => 'Want to Play', 'on_hold' => 'On Hold', 'dropped' => 'Dropped']; $tabs = ['' => 'All', 'playing' => 'Playing', 'played' => 'Played', 'want_to_play' => 'Want to Play', 'on_hold' => 'On Hold', 'dropped' => 'Dropped'];
@endphp @endphp
@foreach($tabs as $tabStatus => $tabLabel) @foreach($tabs as $tabStatus => $tabLabel)
<a href="{{ route('users.library', ['user' => $user->username, 'status' => $tabStatus ?: null]) }}" <a href="{{ route('users.library', ['user' => $user->slug, 'status' => $tabStatus ?: null]) }}"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors class="px-4 py-2 rounded-lg text-sm font-medium transition-colors
{{ $status === $tabStatus || (!$status && !$tabStatus) {{ $status === $tabStatus || (!$status && !$tabStatus)
? 'bg-purple-600 text-white' ? 'bg-purple-600 text-white'
+58 -6
View File
@@ -1,6 +1,7 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', ($user->username ?? $user->name) . ' — GameLog') @section('title', ($user->slug ?? $user->name) . ' — LoreKeepers')
@section('canonical', route('users.show', $user->slug))
@section('content') @section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
@@ -16,8 +17,8 @@
@endif @endif
<div class="flex-1"> <div class="flex-1">
<h1 class="text-2xl font-bold text-white">{{ $user->username ?? $user->name }}</h1> <h1 class="text-2xl font-bold text-white">{{ $user->slug ?? $user->name }}</h1>
@if($user->username && $user->name !== $user->username) @if($user->slug && $user->name !== $user->slug)
<p class="text-slate-500 text-sm">{{ $user->name }}</p> <p class="text-slate-500 text-sm">{{ $user->name }}</p>
@endif @endif
@if($user->bio) @if($user->bio)
@@ -45,11 +46,62 @@
</div> </div>
</div> </div>
<div class="flex gap-2"> <div class="flex items-start gap-6">
<a href="{{ route('users.library', $user->username) }}" {{-- 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"> 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 View Library
</a> </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> </div>
</div> </div>
@@ -73,7 +125,7 @@
<div> <div>
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Recent Library</h2> <h2 class="text-lg font-semibold text-white">Recent Library</h2>
<a href="{{ route('users.library', $user->username) }}" class="text-sm text-purple-400 hover:text-purple-300 transition-colors"> <a href="{{ route('users.library', $user->slug) }}" class="text-sm text-purple-400 hover:text-purple-300 transition-colors">
View all → View all →
</a> </a>
</div> </div>
+23
View File
@@ -0,0 +1,23 @@
<?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']);
});
+3
View File
@@ -2,7 +2,10 @@
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () { Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
Schedule::command('sitemap:generate')->weekly();
+58 -6
View File
@@ -1,5 +1,7 @@
<?php <?php
use App\Http\Controllers\CompareController;
use App\Http\Controllers\GameCommentController;
use App\Http\Controllers\GameController; use App\Http\Controllers\GameController;
use App\Http\Controllers\HomeController; use App\Http\Controllers\HomeController;
use App\Http\Controllers\LibraryController; use App\Http\Controllers\LibraryController;
@@ -18,8 +20,13 @@ Route::get('/new-releases', [GameController::class, 'newReleases'])->name('games
Route::get('/games/{game:slug}', [GameController::class, 'show'])->name('games.show'); Route::get('/games/{game:slug}', [GameController::class, 'show'])->name('games.show');
// User profiles (public) // User profiles (public)
Route::get('/users/{user:username}', [UserController::class, 'show'])->name('users.show'); Route::get('/users/{user:slug}', [UserController::class, 'show'])->name('users.show');
Route::get('/users/{user:username}/library', [UserController::class, 'library'])->name('users.library'); 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');
});
// Library (auth required) // Library (auth required)
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
@@ -35,13 +42,57 @@ Route::middleware('auth')->group(function () {
Route::delete('/reviews/{review}', [\App\Http\Controllers\ReviewController::class, 'destroy'])->name('reviews.destroy'); 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 // 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::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::post('/lists', [\App\Http\Controllers\ListController::class, 'store'])->name('lists.store')->middleware('auth');
Route::get('/lists/{list}', [\App\Http\Controllers\ListController::class, 'show'])->name('lists.show'); Route::get('/lists/{list:slug}', [\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::delete('/lists/{list:slug}', [\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::post('/lists/{list:slug}/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'); 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');
// Dashboard (Breeze default, keep for now) // Dashboard (Breeze default, keep for now)
Route::get('/dashboard', function () { Route::get('/dashboard', function () {
@@ -56,3 +107,4 @@ Route::middleware('auth')->group(function () {
}); });
require __DIR__.'/auth.php'; require __DIR__.'/auth.php';
require __DIR__.'/api.php';
-3
View File
@@ -1,3 +0,0 @@
*
!data/
!.gitignore
-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore