Add follow system, activity feed, notifications, sitemap, caching, Scout search

This commit is contained in:
Mark
2026-05-30 11:23:24 +03:00
parent 5c6ba86e15
commit 3083071a6c
24 changed files with 1657 additions and 49 deletions
@@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use App\Models\Game;
use App\Models\User;
use Illuminate\Console\Command;
use Spatie\Sitemap\Sitemap;
use Spatie\Sitemap\Tags\Url;
class GenerateSitemapCommand extends Command
{
protected $signature = 'sitemap:generate';
protected $description = 'Generate the sitemap.xml file';
public function handle(): int
{
$sitemap = Sitemap::create()
->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;
}
}
@@ -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'));
}
}
+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_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(),
]);
}
}
+18 -12
View File
@@ -6,6 +6,7 @@ use App\Models\Game;
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
{ {
@@ -53,7 +54,10 @@ class GameController extends Controller
$userLists = auth()->user()->lists()->get(['id', 'title']); $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) public function search(Request $request)
@@ -62,15 +66,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 +78,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 +86,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'));
} }
+9 -4
View File
@@ -3,24 +3,29 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Game; use App\Models\Game;
use Illuminate\Support\Facades\Cache;
class HomeController extends Controller class HomeController extends Controller
{ {
public function index() public function index()
{ {
$topRated = Game::published() $topRated = Cache::remember('home.top_rated', 300, fn() =>
Game::published()
->where('rating_count', '>=', 1) ->where('rating_count', '>=', 1)
->orderByDesc('rating_avg') ->orderByDesc('rating_avg')
->orderByDesc('rating_count') ->orderByDesc('rating_count')
->limit(12) ->limit(12)
->get(); ->get()
);
$newReleases = Game::published() $newReleases = Cache::remember('home.new_releases', 300, fn() =>
Game::published()
->whereNotNull('release_date') ->whereNotNull('release_date')
->where('release_date', '<=', now()) ->where('release_date', '<=', now())
->orderByDesc('release_date') ->orderByDesc('release_date')
->limit(8) ->limit(8)
->get(); ->get()
);
return view('home', compact('topRated', 'newReleases')); return view('home', compact('topRated', 'newReleases'));
} }
@@ -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]);
}
}
+15 -1
View File
@@ -5,12 +5,14 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
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', 'cover_url', 'cover_thumb_url',
@@ -60,6 +62,15 @@ class Game extends Model
return $this->hasMany(Review::class)->latest(); return $this->hasMany(Review::class)->latest();
} }
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 +83,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');
} }
} }
+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);
}
}
+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); }
+1
View File
@@ -20,6 +20,7 @@ class User extends Authenticatable
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 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); }
} }
+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);
}
}
+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": "^8.1",
"spatie/laravel-sluggable": "^4.0" "spatie/laravel-sluggable": "^4.0"
}, },
"require-dev": { "require-dev": {
Generated
+427 -3
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": "0b3aad5bce69f3a9e1c97b0cac0cf1db", "content-hash": "5f3c7ba864f94b76dc35fa83162314fa",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -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",
@@ -3445,6 +3525,220 @@
}, },
"time": "2025-12-14T04:43:48+00:00" "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", "name": "spatie/laravel-sluggable",
"version": "4.0.2", "version": "4.0.2",
@@ -3523,6 +3817,66 @@
], ],
"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": "symfony/clock", "name": "symfony/clock",
"version": "v7.4.8", "version": "v7.4.8",
@@ -3839,6 +4193,76 @@
], ],
"time": "2026-04-13T15:52:40+00:00" "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", "name": "symfony/error-handler",
"version": "v7.4.8", "version": "v7.4.8",
@@ -8812,12 +9236,12 @@
], ],
"aliases": [], "aliases": [],
"minimum-stability": "stable", "minimum-stability": "stable",
"stability-flags": {}, "stability-flags": [],
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": "^8.3" "php": "^8.3"
}, },
"platform-dev": {}, "platform-dev": [],
"plugin-api-version": "2.6.0" "plugin-api-version": "2.6.0"
} }
+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('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,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"`
---
+5 -1
View File
@@ -1,2 +1,6 @@
User-agent: * User-agent: *
Disallow: Allow: /
Disallow: /profile
Disallow: /dashboard
Sitemap: https://gamelog.ro/sitemap.xml
+114
View File
@@ -0,0 +1,114 @@
@extends('layouts.app')
@section('title', 'Activity Feed — GameLog')
@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->username) : '#' }}" 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->username) : '#' }}"
class="font-semibold text-white hover:text-purple-300 transition-colors">
{{ $activity->user?->username ?? '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
+16
View File
@@ -41,6 +41,9 @@
<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>
@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 +99,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)
@@ -0,0 +1,66 @@
@extends('layouts.app')
@section('title', 'Notifications — GameLog')
@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_username']))
<a href="{{ route('users.show', $data['follower_username']) }}"
class="font-semibold text-white hover:text-purple-300 transition-colors">
{{ $data['follower_name'] ?? $data['follower_username'] }}
</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_username']))
<a href="{{ route('users.show', $data['follower_username']) }}"
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
+43
View File
@@ -45,13 +45,56 @@
</div> </div>
</div> </div>
<div class="flex items-start gap-6">
{{-- Follower / Following counts --}}
<div class="flex gap-4 text-center">
<div>
<div class="text-lg font-bold text-white">{{ $user->followers()->count() }}</div>
<div class="text-xs text-slate-500 uppercase tracking-wide">Followers</div>
</div>
<div>
<div class="text-lg font-bold text-white">{{ $user->following()->count() }}</div>
<div class="text-xs text-slate-500 uppercase tracking-wide">Following</div>
</div>
</div>
<div class="flex gap-2"> <div class="flex gap-2">
@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->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;
}
}
}">
<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) }}" <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"> 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>
</div> </div>
</div> </div>
</div>
{{-- Top Genres --}} {{-- Top Genres --}}
@if($genreStats->isNotEmpty()) @if($genreStats->isNotEmpty())
+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();
+25
View File
@@ -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::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'); 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) // Dashboard (Breeze default, keep for now)
Route::get('/dashboard', function () { Route::get('/dashboard', function () {
return redirect()->route('home'); return redirect()->route('home');