Add follow system, activity feed, notifications, sitemap, caching, Scout search
This commit is contained in:
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,15 +66,11 @@ 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) => [
|
||||
return response()->json($games->take(6)->map(fn($g) => [
|
||||
'id' => $g->id,
|
||||
'title' => $g->title,
|
||||
'slug' => $g->slug,
|
||||
@@ -78,7 +78,7 @@ class GameController extends Controller
|
||||
'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()
|
||||
$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();
|
||||
->withQueryString()
|
||||
);
|
||||
|
||||
return view('games.top-rated', compact('games'));
|
||||
}
|
||||
|
||||
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')
|
||||
->where('release_date', '<=', now())
|
||||
->orderByDesc('release_date')
|
||||
->paginate(24)
|
||||
->withQueryString();
|
||||
->withQueryString()
|
||||
);
|
||||
|
||||
return view('games.new-releases', compact('games'));
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
$topRated = Cache::remember('home.top_rated', 300, fn() =>
|
||||
Game::published()
|
||||
->where('rating_count', '>=', 1)
|
||||
->orderByDesc('rating_avg')
|
||||
->orderByDesc('rating_count')
|
||||
->limit(12)
|
||||
->get();
|
||||
->get()
|
||||
);
|
||||
|
||||
$newReleases = Game::published()
|
||||
$newReleases = Cache::remember('home.new_releases', 300, fn() =>
|
||||
Game::published()
|
||||
->whereNotNull('release_date')
|
||||
->where('release_date', '<=', now())
|
||||
->orderByDesc('release_date')
|
||||
->limit(8)
|
||||
->get();
|
||||
->get()
|
||||
);
|
||||
|
||||
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
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use App\Services\ActivityService;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
@@ -10,6 +11,21 @@ class UserGame extends Model
|
||||
|
||||
const STATUSES = ['playing','played','want_to_play','on_hold','dropped'];
|
||||
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::created(function (UserGame $ug) {
|
||||
ActivityService::log($ug->user, 'added_to_library', $ug->game, ['status' => $ug->status]);
|
||||
});
|
||||
|
||||
static::updated(function (UserGame $ug) {
|
||||
if ($ug->isDirty('rating') && $ug->rating) {
|
||||
ActivityService::log($ug->user, 'rated_game', $ug->game, ['rating' => $ug->rating]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function user(): BelongsTo { return $this->belongsTo(User::class); }
|
||||
public function game(): BelongsTo { return $this->belongsTo(Game::class); }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
Generated
+427
-3
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,2 +1,6 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
Allow: /
|
||||
Disallow: /profile
|
||||
Disallow: /dashboard
|
||||
|
||||
Sitemap: https://gamelog.ro/sitemap.xml
|
||||
|
||||
@@ -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
|
||||
@@ -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.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>
|
||||
@auth
|
||||
<a href="{{ route('activity.index') }}" class="text-sm text-slate-400 hover:text-slate-200 transition-colors">Activity</a>
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,6 +99,19 @@
|
||||
<!-- Right: Auth -->
|
||||
<div class="flex items-center gap-3">
|
||||
@auth
|
||||
{{-- Notification bell --}}
|
||||
@php $unreadCount = auth()->user()->notifications()->whereNull('read_at')->count(); @endphp
|
||||
<a href="{{ route('notifications.index') }}" class="relative text-slate-400 hover:text-slate-200 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
|
||||
</svg>
|
||||
@if($unreadCount > 0)
|
||||
<span class="absolute -top-1 -right-1 w-4 h-4 bg-purple-600 rounded-full text-[10px] font-bold text-white flex items-center justify-center">
|
||||
{{ $unreadCount > 9 ? '9+' : $unreadCount }}
|
||||
</span>
|
||||
@endif
|
||||
</a>
|
||||
<div class="relative" x-data="{ open: false }">
|
||||
<button @click="open = !open" class="flex items-center gap-2 text-sm text-slate-400 hover:text-slate-200 transition-colors">
|
||||
@if(auth()->user()->avatar)
|
||||
|
||||
@@ -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
|
||||
@@ -45,13 +45,56 @@
|
||||
</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">
|
||||
@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) }}"
|
||||
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>
|
||||
</div>
|
||||
|
||||
{{-- Top Genres --}}
|
||||
@if($genreStats->isNotEmpty())
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user