diff --git a/app/Console/Commands/GenerateSitemapCommand.php b/app/Console/Commands/GenerateSitemapCommand.php new file mode 100644 index 0000000..8fdaf0b --- /dev/null +++ b/app/Console/Commands/GenerateSitemapCommand.php @@ -0,0 +1,54 @@ +add(Url::create('/')->setPriority(1.0)->setChangeFrequency(Url::CHANGE_FREQUENCY_DAILY)) + ->add(Url::create('/games')->setPriority(0.9)->setChangeFrequency(Url::CHANGE_FREQUENCY_DAILY)) + ->add(Url::create('/top-rated')->setPriority(0.8)->setChangeFrequency(Url::CHANGE_FREQUENCY_WEEKLY)) + ->add(Url::create('/new-releases')->setPriority(0.8)->setChangeFrequency(Url::CHANGE_FREQUENCY_DAILY)); + + Game::select('slug', 'updated_at')->chunk(500, function ($games) use ($sitemap) { + foreach ($games as $game) { + $sitemap->add( + Url::create("/games/{$game->slug}") + ->setLastModificationDate($game->updated_at) + ->setPriority(0.7) + ->setChangeFrequency(Url::CHANGE_FREQUENCY_WEEKLY) + ); + } + }); + + User::where('is_private', false) + ->select('username', 'updated_at') + ->chunk(500, function ($users) use ($sitemap) { + foreach ($users as $user) { + $sitemap->add( + Url::create("/users/{$user->username}") + ->setLastModificationDate($user->updated_at) + ->setPriority(0.5) + ->setChangeFrequency(Url::CHANGE_FREQUENCY_WEEKLY) + ); + } + }); + + $sitemap->writeToFile(public_path('sitemap.xml')); + + $this->info('Sitemap generated at public/sitemap.xml'); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/ActivityController.php b/app/Http/Controllers/ActivityController.php new file mode 100644 index 0000000..f0f3609 --- /dev/null +++ b/app/Http/Controllers/ActivityController.php @@ -0,0 +1,30 @@ +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')); + } +} diff --git a/app/Http/Controllers/FollowController.php b/app/Http/Controllers/FollowController.php new file mode 100644 index 0000000..df66872 --- /dev/null +++ b/app/Http/Controllers/FollowController.php @@ -0,0 +1,49 @@ +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_username' => $me->username, + '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(), + ]); + } +} diff --git a/app/Http/Controllers/GameController.php b/app/Http/Controllers/GameController.php index 96e252c..f32216f 100644 --- a/app/Http/Controllers/GameController.php +++ b/app/Http/Controllers/GameController.php @@ -6,6 +6,7 @@ use App\Models\Game; use App\Models\Genre; use App\Models\Platform; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; class GameController extends Controller { @@ -53,7 +54,10 @@ class GameController extends Controller $userLists = auth()->user()->lists()->get(['id', 'title']); } - return view('games.show', compact('game', 'userGame', 'userReview', 'userLists')); + $response = view('games.show', compact('game', 'userGame', 'userReview', 'userLists')); + return auth()->check() + ? $response + : response($response)->header('Cache-Control', 'public, max-age=300'); } public function search(Request $request) @@ -62,23 +66,19 @@ class GameController extends Controller $games = collect(); if (strlen($q) >= 2) { - $games = Game::published() - ->where('title', 'like', "%{$q}%") - ->orderByDesc('rating_count') - ->limit(20) - ->get(); + $games = Game::search($q)->get()->filter(fn($g) => $g->status === 'published'); } if ($request->expectsJson()) { - return response()->json($games->map(fn($g) => [ - 'id' => $g->id, - 'title' => $g->title, - 'slug' => $g->slug, - 'cover_thumb' => $g->cover_thumb_url, - 'release_year' => $g->release_year, - 'rating_avg' => $g->rating_avg, - 'url' => route('games.show', $g), - ])); + return response()->json($games->take(6)->map(fn($g) => [ + 'id' => $g->id, + 'title' => $g->title, + 'slug' => $g->slug, + 'cover_thumb' => $g->cover_thumb_url, + 'release_year' => $g->release_year, + 'rating_avg' => $g->rating_avg, + 'url' => route('games.show', $g), + ])->values()); } return view('games.search', compact('games', 'q')); @@ -86,24 +86,30 @@ class GameController extends Controller public function topRated(Request $request) { - $games = Game::published() - ->where('rating_count', '>=', 1) - ->orderByDesc('rating_avg') - ->orderByDesc('rating_count') - ->paginate(24) - ->withQueryString(); + $page = $request->get('page', 1); + $games = Cache::remember("games.top_rated.{$page}", 300, fn() => + Game::published() + ->where('rating_count', '>=', 1) + ->orderByDesc('rating_avg') + ->orderByDesc('rating_count') + ->paginate(24) + ->withQueryString() + ); return view('games.top-rated', compact('games')); } public function newReleases(Request $request) { - $games = Game::published() - ->whereNotNull('release_date') - ->where('release_date', '<=', now()) - ->orderByDesc('release_date') - ->paginate(24) - ->withQueryString(); + $page = $request->get('page', 1); + $games = Cache::remember("games.new_releases.{$page}", 300, fn() => + Game::published() + ->whereNotNull('release_date') + ->where('release_date', '<=', now()) + ->orderByDesc('release_date') + ->paginate(24) + ->withQueryString() + ); return view('games.new-releases', compact('games')); } diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 4bdebaa..5f57739 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -3,24 +3,29 @@ namespace App\Http\Controllers; use App\Models\Game; +use Illuminate\Support\Facades\Cache; class HomeController extends Controller { public function index() { - $topRated = Game::published() - ->where('rating_count', '>=', 1) - ->orderByDesc('rating_avg') - ->orderByDesc('rating_count') - ->limit(12) - ->get(); + $topRated = Cache::remember('home.top_rated', 300, fn() => + Game::published() + ->where('rating_count', '>=', 1) + ->orderByDesc('rating_avg') + ->orderByDesc('rating_count') + ->limit(12) + ->get() + ); - $newReleases = Game::published() - ->whereNotNull('release_date') - ->where('release_date', '<=', now()) - ->orderByDesc('release_date') - ->limit(8) - ->get(); + $newReleases = Cache::remember('home.new_releases', 300, fn() => + Game::published() + ->whereNotNull('release_date') + ->where('release_date', '<=', now()) + ->orderByDesc('release_date') + ->limit(8) + ->get() + ); return view('home', compact('topRated', 'newReleases')); } diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php new file mode 100644 index 0000000..0849737 --- /dev/null +++ b/app/Http/Controllers/NotificationController.php @@ -0,0 +1,27 @@ +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]); + } +} diff --git a/app/Models/Game.php b/app/Models/Game.php index 41e0aed..ede2bc2 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -5,12 +5,14 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Facades\Cache; +use Laravel\Scout\Searchable; use Spatie\Sluggable\HasSlug; use Spatie\Sluggable\SlugOptions; class Game extends Model { - use HasSlug; + use HasSlug, Searchable; protected $fillable = [ 'igdb_id', 'slug', 'title', 'summary', 'cover_url', 'cover_thumb_url', @@ -60,6 +62,15 @@ class Game extends Model return $this->hasMany(Review::class)->latest(); } + public function toSearchableArray(): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'summary' => $this->summary, + ]; + } + public function scopePublished($query) { return $query->where('status', 'published'); @@ -72,5 +83,8 @@ class Game extends Model 'rating_avg' => $stats->avg ? round($stats->avg, 2) : null, 'rating_count' => $stats->cnt ?? 0, ]); + + Cache::forget('home.top_rated'); + Cache::forget('games.top_rated.1'); } } diff --git a/app/Models/Notification.php b/app/Models/Notification.php new file mode 100644 index 0000000..ce02fa6 --- /dev/null +++ b/app/Models/Notification.php @@ -0,0 +1,25 @@ + 'array', + 'read_at' => 'datetime', + 'created_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Review.php b/app/Models/Review.php index 495fb36..2625605 100644 --- a/app/Models/Review.php +++ b/app/Models/Review.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Services\ActivityService; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -23,6 +24,10 @@ class Review extends Model static::deleted(function (Review $review) { $review->game->recalculateRating(); }); + + static::created(function (Review $review) { + ActivityService::log($review->user, 'reviewed_game', $review->game, ['rating' => $review->rating]); + }); } public function user(): BelongsTo { return $this->belongsTo(User::class); } diff --git a/app/Models/User.php b/app/Models/User.php index 46a3602..3f10e38 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -20,6 +20,7 @@ class User extends Authenticatable public function reviews(): HasMany { return $this->hasMany(Review::class); } public function lists(): HasMany { return $this->hasMany(UserList::class); } public function activities(): HasMany { return $this->hasMany(Activity::class)->latest(); } + public function notifications(): HasMany { return $this->hasMany(Notification::class)->latest('created_at'); } public function following(): BelongsToMany { diff --git a/app/Models/UserGame.php b/app/Models/UserGame.php index 676d56a..e52cdc4 100644 --- a/app/Models/UserGame.php +++ b/app/Models/UserGame.php @@ -1,5 +1,6 @@ user, 'added_to_library', $ug->game, ['status' => $ug->status]); + }); + + static::updated(function (UserGame $ug) { + if ($ug->isDirty('rating') && $ug->rating) { + ActivityService::log($ug->user, 'rated_game', $ug->game, ['rating' => $ug->rating]); + } + }); + } + public function user(): BelongsTo { return $this->belongsTo(User::class); } public function game(): BelongsTo { return $this->belongsTo(Game::class); } } diff --git a/app/Services/ActivityService.php b/app/Services/ActivityService.php new file mode 100644 index 0000000..c9bae62 --- /dev/null +++ b/app/Services/ActivityService.php @@ -0,0 +1,15 @@ +id, $type, $subject, $data); + } +} diff --git a/composer.json b/composer.json index cb8115f..d7599ad 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,9 @@ "guzzlehttp/guzzle": "^7.10", "intervention/image": "^4.1", "laravel/framework": "^13.8", + "laravel/scout": "^11.2", "laravel/tinker": "^3.0", + "spatie/laravel-sitemap": "^8.1", "spatie/laravel-sluggable": "^4.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index c15fe7a..6bb2392 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0b3aad5bce69f3a9e1c97b0cac0cf1db", + "content-hash": "5f3c7ba864f94b76dc35fa83162314fa", "packages": [ { "name": "brick/math", @@ -1481,6 +1481,86 @@ }, "time": "2026-05-19T00:47:18+00:00" }, + { + "name": "laravel/scout", + "version": "v11.2.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/scout.git", + "reference": "5a267813fe0d7bca58b993c3bae592b2c814591e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/scout/zipball/5a267813fe0d7bca58b993c3bae592b2c814591e", + "reference": "5a267813fe0d7bca58b993c3bae592b2c814591e", + "shasum": "" + }, + "require": { + "illuminate/bus": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/pagination": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/queue": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "conflict": { + "algolia/algoliasearch-client-php": "<3.2.0|>=5.0.0" + }, + "require-dev": { + "algolia/algoliasearch-client-php": "^3.2|^4.0", + "meilisearch/meilisearch-php": "^1.0", + "mockery/mockery": "^1.0", + "orchestra/testbench": "^7.31|^8.36|^9.15|^10.8|^11.0", + "php-http/guzzle7-adapter": "^1.0", + "phpstan/phpstan": "^1.10", + "typesense/typesense-php": "^4.9.3" + }, + "suggest": { + "algolia/algoliasearch-client-php": "Required to use the Algolia engine (^3.2).", + "meilisearch/meilisearch-php": "Required to use the Meilisearch engine (^1.0).", + "typesense/typesense-php": "Required to use the Typesense engine (^4.9)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Scout\\ScoutServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Scout\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Scout provides a driver based solution to searching your Eloquent models.", + "keywords": [ + "algolia", + "laravel", + "search" + ], + "support": { + "issues": "https://github.com/laravel/scout/issues", + "source": "https://github.com/laravel/scout" + }, + "time": "2026-05-13T15:50:06+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.13", @@ -3445,6 +3525,220 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "spatie/crawler", + "version": "9.3.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/crawler.git", + "reference": "0c0b6e4928040d90c1d0508fd6e5655e8a51067c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/crawler/zipball/0c0b6e4928040d90c1d0508fd6e5655e8a51067c", + "reference": "0c0b6e4928040d90c1d0508fd6e5655e8a51067c", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.3", + "guzzlehttp/psr7": "^2.0", + "php": "^8.4", + "spatie/robots-txt": "^2.0", + "symfony/css-selector": "^7.0|^8.0", + "symfony/dom-crawler": "^7.0|^8.0" + }, + "require-dev": { + "pestphp/pest": "^4.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "spatie/invade": "^2.1", + "spatie/ray": "^1.37" + }, + "suggest": { + "spatie/browsershot": "Required for JavaScript rendering with BrowsershotRenderer (^5.0.5)" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-v9": "9.x-dev" + } + }, + "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/9.3.1" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-05-18T13:26:09+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": "8.1.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-sitemap.git", + "reference": "5a8d3467f670d4dbe277c00bf960617912c97fb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-sitemap/zipball/5a8d3467f670d4dbe277c00bf960617912c97fb1", + "reference": "5a8d3467f670d4dbe277c00bf960617912c97fb1", + "shasum": "" + }, + "require": { + "illuminate/support": "^12.0|^13.0", + "nesbot/carbon": "^2.71|^3.0", + "php": "^8.4", + "spatie/crawler": "^9.0", + "spatie/laravel-package-tools": "^1.16.1" + }, + "require-dev": { + "larastan/larastan": "^3.0", + "laravel/pint": "^1.13", + "orchestra/testbench": "^10.0|^11.0", + "pestphp/pest": "^4.0", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "spatie/pest-plugin-snapshots": "^2.1", + "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/8.1.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + } + ], + "time": "2026-03-12T09:30:40+00:00" + }, { "name": "spatie/laravel-sluggable", "version": "4.0.2", @@ -3523,6 +3817,66 @@ ], "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": "symfony/clock", "version": "v7.4.8", @@ -3839,6 +4193,76 @@ ], "time": "2026-04-13T15:52:40+00:00" }, + { + "name": "symfony/dom-crawler", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "77ca351474ea018daba5f2e473cbf1b9b8e72ac6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/77ca351474ea018daba5f2e473cbf1b9b8e72ac6", + "reference": "77ca351474ea018daba5f2e473cbf1b9b8e72ac6", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.0" + }, + "require-dev": { + "symfony/css-selector": "^7.4|^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/v8.1.0" + }, + "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-29T05:06:50+00:00" + }, { "name": "symfony/error-handler", "version": "v7.4.8", @@ -8812,12 +9236,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^8.3" }, - "platform-dev": {}, + "platform-dev": [], "plugin-api-version": "2.6.0" } diff --git a/config/scout.php b/config/scout.php new file mode 100644 index 0000000..57bc34b --- /dev/null +++ b/config/scout.php @@ -0,0 +1,210 @@ + 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'), + ], + +]; diff --git a/database/migrations/2026_05_30_000001_create_notifications_table.php b/database/migrations/2026_05_30_000001_create_notifications_table.php new file mode 100644 index 0000000..f7ba2db --- /dev/null +++ b/database/migrations/2026_05_30_000001_create_notifications_table.php @@ -0,0 +1,25 @@ +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'); + } +}; diff --git a/docs/superpowers/plans/2026-05-30-follow-feed-notifications-sitemap-search-cache.md b/docs/superpowers/plans/2026-05-30-follow-feed-notifications-sitemap-search-cache.md new file mode 100644 index 0000000..4fd7e53 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-follow-feed-notifications-sitemap-search-cache.md @@ -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 +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 +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 + '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 `
` (replaces the lone "View Library" button container) + +Replace the `
` block with: + +```html +
+ {{-- Follower / Following counts --}} +
+
+
{{ $user->followers()->count() }}
+
Followers
+
+
+
{{ $user->following()->count() }}
+
Following
+
+
+ +
+ @auth + @if(auth()->id() !== $user->id) +
+ +
+ @endif + @endauth + + View Library + +
+
+``` + +- [ ] 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 +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 +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 +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"` + +--- diff --git a/public/robots.txt b/public/robots.txt index eb05362..12aa2e9 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,2 +1,6 @@ User-agent: * -Disallow: +Allow: / +Disallow: /profile +Disallow: /dashboard + +Sitemap: https://gamelog.ro/sitemap.xml diff --git a/resources/views/activity/index.blade.php b/resources/views/activity/index.blade.php new file mode 100644 index 0000000..aabc001 --- /dev/null +++ b/resources/views/activity/index.blade.php @@ -0,0 +1,114 @@ +@extends('layouts.app') + +@section('title', 'Activity Feed — GameLog') + +@section('content') +
+ +
+

Activity Feed

+ +
+ + @if($activities->isEmpty()) +
+
+ + + +
+

No activity yet

+

Follow some users to see their activity here.

+ + Browse Games + +
+ @else +
+ @foreach($activities as $activity) + @php + $game = $activity->subject; + @endphp +
+ {{-- User avatar --}} + + @if($activity->user?->avatar) + + @else +
+ {{ strtoupper(substr($activity->user?->name ?? '?', 0, 1)) }} +
+ @endif +
+ + {{-- Game thumbnail --}} + @if($game && $game->cover_thumb_url) + + {{ $game->title }} + + @endif + + {{-- Activity text --}} +
+

+ + {{ $activity->user?->username ?? 'Unknown' }} + + + @if($activity->type === 'added_to_library') + added + @if($game) + {{ $game->title }} + @endif + to their library + @if(!empty($activity->data['status'])) + as {{ str_replace('_', ' ', $activity->data['status']) }} + @endif + + @elseif($activity->type === 'rated_game') + rated + @if($game) + {{ $game->title }} + @endif + {{ $activity->data['rating'] ?? '' }}/10 + + @elseif($activity->type === 'reviewed_game') + reviewed + @if($game) + {{ $game->title }} + @endif + @if(!empty($activity->data['rating'])) + — {{ $activity->data['rating'] }}/10 + @endif + + @else + {{ str_replace('_', ' ', $activity->type) }} + @if($game) + {{ $game->title }} + @endif + @endif +

+

{{ $activity->created_at?->diffForHumans() }}

+
+
+ @endforeach +
+ +
+ {{ $activities->links() }} +
+ @endif + +
+@endsection diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index f7b3d94..94c9a45 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -41,6 +41,9 @@ Browse Top Rated New Releases + @auth + Activity + @endauth
@@ -96,6 +99,19 @@
@auth + {{-- Notification bell --}} + @php $unreadCount = auth()->user()->notifications()->whereNull('read_at')->count(); @endphp + + + + + @if($unreadCount > 0) + + {{ $unreadCount > 9 ? '9+' : $unreadCount }} + + @endif +
-
- - View Library - +
+ {{-- Follower / Following counts --}} +
+
+
{{ $user->followers()->count() }}
+
Followers
+
+
+
{{ $user->following()->count() }}
+
Following
+
+
+ +
+ @auth + @if(auth()->id() !== $user->id) +
+ +
+ @endif + @endauth + + View Library + +
diff --git a/routes/console.php b/routes/console.php index 3c9adf1..9d8c085 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,10 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::command('sitemap:generate')->weekly(); diff --git a/routes/web.php b/routes/web.php index b30e779..e605951 100644 --- a/routes/web.php +++ b/routes/web.php @@ -43,6 +43,31 @@ Route::delete('/lists/{list}', [\App\Http\Controllers\ListController::class, 'de Route::post('/lists/{list}/games', [\App\Http\Controllers\ListController::class, 'addGame'])->name('lists.add-game')->middleware('auth'); Route::delete('/lists/{list}/games/{game}', [\App\Http\Controllers\ListController::class, 'removeGame'])->name('lists.remove-game')->middleware('auth'); +// Follow system +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'); +}); + +// 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'); + // Dashboard (Breeze default, keep for now) Route::get('/dashboard', function () { return redirect()->route('home');