From 115f81fe960bad15fc97f117dff6ef447e03af53 Mon Sep 17 00:00:00 2001 From: Refringe Date: Sat, 20 Jul 2024 13:28:38 -0400 Subject: [PATCH 01/15] Basic structure to load user profile view --- app/Http/Controllers/UserController.php | 25 ++++++++++ app/Models/User.php | 16 +++++++ app/Policies/ModPolicy.php | 4 +- app/Policies/UserPolicy.php | 47 +++++++++++++++++++ resources/views/components/mod-list.blade.php | 4 +- resources/views/mod/show.blade.php | 7 ++- resources/views/user/show.blade.php | 9 ++++ routes/web.php | 7 ++- 8 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 app/Http/Controllers/UserController.php create mode 100644 app/Policies/UserPolicy.php create mode 100644 resources/views/user/show.blade.php diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php new file mode 100644 index 0000000..a8e86d5 --- /dev/null +++ b/app/Http/Controllers/UserController.php @@ -0,0 +1,25 @@ +slug() !== $username) { + abort(404); + } + + if ($request->user()?->cannot('view', $user)) { + abort(403); + } + + return view('user.show', compact('user')); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 224e5b5..655ec0c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -94,6 +94,22 @@ class User extends Authenticatable implements MustVerifyEmail $this->notify(new ResetPassword($token)); } + /** + * Get the relative URL to the user's profile page. + */ + public function profileUrl(): string + { + return route('user.show', [ + 'user' => $this->id, + 'username' => $this->slug(), + ]); + } + + public function slug(): string + { + return Str::lower(Str::slug($this->name)); + } + protected function casts(): array { return [ diff --git a/app/Policies/ModPolicy.php b/app/Policies/ModPolicy.php index 0d18b6a..7c4e3ec 100644 --- a/app/Policies/ModPolicy.php +++ b/app/Policies/ModPolicy.php @@ -8,7 +8,7 @@ use App\Models\User; class ModPolicy { /** - * Determine whether the user can view any models. + * Determine whether the user can view multiple models. */ public function viewAny(User $user): bool { @@ -16,7 +16,7 @@ class ModPolicy } /** - * Determine whether the user can view the model. + * Determine whether the user can view a specific model. */ public function view(?User $user, Mod $mod): bool { diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php new file mode 100644 index 0000000..8878cd8 --- /dev/null +++ b/app/Policies/UserPolicy.php @@ -0,0 +1,47 @@ +{$versionScope}->sptVersion->version }} -

By {{ $mod->users->pluck('name')->implode(', ') }}

+

+ By {{ $mod->users->pluck('name')->implode(', ') }} +

{{ $mod->teaser }}

diff --git a/resources/views/mod/show.blade.php b/resources/views/mod/show.blade.php index cf3f3e8..00ae244 100644 --- a/resources/views/mod/show.blade.php +++ b/resources/views/mod/show.blade.php @@ -32,7 +32,12 @@ {{ $latestVersion->sptVersion->version }} -

{{ __('Created by') }} {{ $mod->users->pluck('name')->implode(', ') }}

+

+ {{ __('Created by') }} + @foreach ($mod->users as $user) + {{ $user->name }}{{ $loop->last ? '' : ',' }} + @endforeach +

{{ $latestVersion->sptVersion->version }} {{ __('Compatible') }}

{{ Number::format($mod->total_downloads) }} {{ __('Downloads') }}

diff --git a/resources/views/user/show.blade.php b/resources/views/user/show.blade.php new file mode 100644 index 0000000..e484714 --- /dev/null +++ b/resources/views/user/show.blade.php @@ -0,0 +1,9 @@ + + + +

+ {{ $user->name }} +

+
+ +
diff --git a/routes/web.php b/routes/web.php index 57d33f6..4641d92 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ group(function () { @@ -11,7 +12,11 @@ Route::middleware(['auth.banned'])->group(function () { Route::controller(ModController::class)->group(function () { Route::get('/mods', 'index')->name('mods'); - Route::get('/mod/{mod}/{slug}', 'show')->where(['id' => '[0-9]+'])->name('mod.show'); + Route::get('/mod/{mod}/{slug}', 'show')->where(['mod' => '[0-9]+'])->name('mod.show'); + }); + + Route::controller(UserController::class)->group(function () { + Route::get('/user/{user}/{username}', 'show')->where(['user' => '[0-9]+'])->name('user.show'); }); Route::middleware(['auth:sanctum', config('jetstream.auth_session'), 'verified'])->group(function () { From 0e3d32c4d59db028b30c96b0422ba82f084b51f3 Mon Sep 17 00:00:00 2001 From: Refringe Date: Sat, 20 Jul 2024 15:08:13 -0400 Subject: [PATCH 02/15] Start of Profile Page Profile photo is linked up, cover photo is not. Both profile and cover photos need to be migrated over from the hub. That's next. --- resources/views/user/show.blade.php | 36 +++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/resources/views/user/show.blade.php b/resources/views/user/show.blade.php index e484714..6c149ab 100644 --- a/resources/views/user/show.blade.php +++ b/resources/views/user/show.blade.php @@ -1,9 +1,35 @@ +
+
+ +
+
+
+
+ {{ $user->name }} +
+
+
+

{{ $user->name }}

+
+ {{-- +
+ +
+ --}} +
+
+ +
+
- -

- {{ $user->name }} -

-
From 11453db596da60e3bdab8d4e6f23bf5cbe306db7 Mon Sep 17 00:00:00 2001 From: Refringe Date: Sat, 20 Jul 2024 19:52:36 -0400 Subject: [PATCH 03/15] Model Comments, Yay! --- app/Models/License.php | 3 +++ app/Models/Mod.php | 28 ++++++++++++++++++++------ app/Models/ModVersion.php | 9 +++++++++ app/Models/SptVersion.php | 3 +++ app/Models/User.php | 31 ++++++++++++++++++++++------- app/Models/UserRole.php | 3 +++ resources/views/user/show.blade.php | 2 +- 7 files changed, 65 insertions(+), 14 deletions(-) diff --git a/app/Models/License.php b/app/Models/License.php index d67c71e..c678a35 100644 --- a/app/Models/License.php +++ b/app/Models/License.php @@ -11,6 +11,9 @@ class License extends Model { use HasFactory, SoftDeletes; + /** + * The relationship between a license and mod. + */ public function mods(): HasMany { return $this->hasMany(Mod::class); diff --git a/app/Models/Mod.php b/app/Models/Mod.php index 81b7391..8a0e512 100644 --- a/app/Models/Mod.php +++ b/app/Models/Mod.php @@ -25,6 +25,9 @@ class Mod extends Model { use HasFactory, Searchable, SoftDeletes; + /** + * Post boot method to configure the model. + */ protected static function booted(): void { // Apply the global scope to exclude disabled mods. @@ -34,18 +37,24 @@ class Mod extends Model } /** - * The users that belong to the mod. + * The relationship between a mod and its users. */ public function users(): BelongsToMany { return $this->belongsToMany(User::class); } + /** + * The relationship between a mod and its license. + */ public function license(): BelongsTo { return $this->belongsTo(License::class); } + /** + * The relationship between a mod and its versions. + */ public function versions(): HasMany { return $this->hasMany(ModVersion::class)->orderByDesc('version'); @@ -62,6 +71,9 @@ class Mod extends Model ]); } + /** + * The relationship between a mod and its last updated version. + */ public function lastUpdatedVersion(): HasOne { return $this->hasOne(ModVersion::class) @@ -69,7 +81,7 @@ class Mod extends Model } /** - * Get the indexable data array for the model. + * The data that is searchable by Scout. */ public function toSearchableArray(): array { @@ -97,11 +109,12 @@ class Mod extends Model { return $this->hasOne(ModVersion::class) ->orderByDesc('version') + ->orderByDesc('updated_at') ->take(1); } /** - * Determine if the model should be searchable. + * Determine if the model instance should be searchable. */ public function shouldBeSearchable(): bool { @@ -109,7 +122,7 @@ class Mod extends Model } /** - * Get the URL to the thumbnail. + * Build the URL to the mod's thumbnail. */ public function thumbnailUrl(): Attribute { @@ -121,7 +134,7 @@ class Mod extends Model } /** - * Get the disk where the thumbnail is stored. + * Get the disk where the thumbnail is stored based on the current environment. */ protected function thumbnailDisk(): string { @@ -131,6 +144,9 @@ class Mod extends Model }; } + /** + * The attributes that should be cast to native types. + */ protected function casts(): array { return [ @@ -142,7 +158,7 @@ class Mod extends Model } /** - * Ensure the slug is always lower case when retrieved and slugified when saved. + * Mutate the slug attribute to always be lower case on get and slugified on set. */ protected function slug(): Attribute { diff --git a/app/Models/ModVersion.php b/app/Models/ModVersion.php index 57669db..faaea23 100644 --- a/app/Models/ModVersion.php +++ b/app/Models/ModVersion.php @@ -19,12 +19,18 @@ class ModVersion extends Model { use HasFactory, SoftDeletes; + /** + * Post boot method to configure the model. + */ protected static function booted(): void { static::addGlobalScope(new DisabledScope); static::addGlobalScope(new PublishedScope); } + /** + * The relationship between a mod version and mod. + */ public function mod(): BelongsTo { return $this->belongsTo(Mod::class); @@ -38,6 +44,9 @@ class ModVersion extends Model return $this->hasMany(ModDependency::class); } + /** + * The relationship between a mod version and SPT version. + */ public function sptVersion(): BelongsTo { return $this->belongsTo(SptVersion::class); diff --git a/app/Models/SptVersion.php b/app/Models/SptVersion.php index 518a308..0a05dd0 100644 --- a/app/Models/SptVersion.php +++ b/app/Models/SptVersion.php @@ -11,6 +11,9 @@ class SptVersion extends Model { use HasFactory, SoftDeletes; + /** + * The relationship between an SPT version and mod version. + */ public function modVersions(): HasMany { return $this->hasMany(ModVersion::class); diff --git a/app/Models/User.php b/app/Models/User.php index 655ec0c..b8982dd 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -38,11 +38,17 @@ class User extends Authenticatable implements MustVerifyEmail 'profile_photo_url', ]; + /** + * The relationship between a user and their mods. + */ public function mods(): BelongsToMany { return $this->belongsToMany(Mod::class); } + /** + * The data that is searchable by Scout. + */ public function toSearchableArray(): array { return [ @@ -51,28 +57,33 @@ class User extends Authenticatable implements MustVerifyEmail ]; } + /** + * Determine if the model instance should be searchable. + */ public function shouldBeSearchable(): bool { return ! is_null($this->email_verified_at); } - public function assignRole(UserRole $role): bool - { - $this->role()->associate($role); - - return $this->save(); - } - + /** + * The relationship between a user and their role. + */ public function role(): BelongsTo { return $this->belongsTo(UserRole::class, 'user_role_id'); } + /** + * Check if the user has the role of a moderator. + */ public function isMod(): bool { return Str::lower($this->role?->name) === 'moderator'; } + /** + * Check if the user has the role of an administrator. + */ public function isAdmin(): bool { return Str::lower($this->role?->name) === 'administrator'; @@ -105,11 +116,17 @@ class User extends Authenticatable implements MustVerifyEmail ]); } + /** + * Get the slug of the user's name. + */ public function slug(): string { return Str::lower(Str::slug($this->name)); } + /** + * The attributes that should be cast to native types. + */ protected function casts(): array { return [ diff --git a/app/Models/UserRole.php b/app/Models/UserRole.php index df600ef..e27e57f 100644 --- a/app/Models/UserRole.php +++ b/app/Models/UserRole.php @@ -10,6 +10,9 @@ class UserRole extends Model { use HasFactory; + /** + * The relationship between a user role and users. + */ public function users(): HasMany { return $this->hasMany(User::class); diff --git a/resources/views/user/show.blade.php b/resources/views/user/show.blade.php index 6c149ab..943e592 100644 --- a/resources/views/user/show.blade.php +++ b/resources/views/user/show.blade.php @@ -1,4 +1,5 @@ +
@@ -31,5 +32,4 @@
-
From 55273e5a901a472ada60db7d334017bfdb54962d Mon Sep 17 00:00:00 2001 From: Refringe Date: Sun, 21 Jul 2024 01:29:07 -0400 Subject: [PATCH 04/15] Hub Imports of User Avatars and Cover Photos TODO: Cover photos need to be added to the profile page so users can edit them. --- app/Jobs/ImportHubData.php | 161 ++++++++++++++---- app/Models/User.php | 26 ++- config/horizon.php | 2 +- .../0001_01_01_000000_create_users_table.php | 3 +- 4 files changed, 149 insertions(+), 43 deletions(-) diff --git a/app/Jobs/ImportHubData.php b/app/Jobs/ImportHubData.php index 5edcd40..74ac2b1 100644 --- a/app/Jobs/ImportHubData.php +++ b/app/Jobs/ImportHubData.php @@ -36,6 +36,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue { // Stream some data locally so that we don't have to keep accessing the Hub's database. Use MySQL temporary // tables to store the data to save on memory; we don't want this to be a hog. + $this->bringUserAvatarLocal(); $this->bringFileAuthorsLocal(); $this->bringFileOptionsLocal(); $this->bringFileContentLocal(); @@ -58,6 +59,34 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue Artisan::call('cache:clear'); } + /** + * Bring the user avatar table from the Hub database to the local database temporary table. + */ + protected function bringUserAvatarLocal(): void + { + DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_user_avatar'); + DB::statement('CREATE TEMPORARY TABLE temp_user_avatar ( + avatarID INT, + avatarExtension VARCHAR(255), + userID INT, + fileHash VARCHAR(255) + )'); + + DB::connection('mysql_hub') + ->table('wcf1_user_avatar') + ->orderBy('avatarID') + ->chunk(200, function ($avatars) { + foreach ($avatars as $avatar) { + DB::table('temp_user_avatar')->insert([ + 'avatarID' => (int) $avatar->avatarID, + 'avatarExtension' => $avatar->avatarExtension, + 'userID' => (int) $avatar->userID, + 'fileHash' => $avatar->fileHash, + ]); + } + }); + } + /** * Bring the file authors from the Hub database to the local database temporary table. */ @@ -172,15 +201,33 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue */ protected function importUsers(): void { + // Initialize a cURL handler for downloading mod thumbnails. + $curl = curl_init(); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true); + DB::connection('mysql_hub') ->table('wcf1_user as u') - ->select('u.userID', 'u.username', 'u.email', 'u.password', 'u.registrationDate', 'u.banned', 'u.banReason', 'u.banExpires', 'u.rankID', 'r.rankTitle') + ->select( + 'u.userID', + 'u.username', + 'u.email', + 'u.password', + 'u.registrationDate', + 'u.banned', + 'u.banReason', + 'u.banExpires', + 'u.coverPhotoHash', + 'u.coverPhotoExtension', + 'u.rankID', + 'r.rankTitle', + ) ->leftJoin('wcf1_user_rank as r', 'u.rankID', '=', 'r.rankID') - ->chunkById(250, function (Collection $users) { + ->chunkById(250, function (Collection $users) use ($curl) { $userData = $bannedUsers = $userRanks = []; foreach ($users as $user) { - $userData[] = $this->collectUserData($user); + $userData[] = $this->collectUserData($curl, $user); $bannedUserData = $this->collectBannedUserData($user); if ($bannedUserData) { @@ -197,15 +244,20 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue $this->handleBannedUsers($bannedUsers); $this->handleUserRoles($userRanks); }, 'userID'); + + // Close the cURL handler. + curl_close($curl); } - protected function collectUserData($user): array + protected function collectUserData(CurlHandle $curl, object $user): array { return [ 'hub_id' => (int) $user->userID, 'name' => $user->username, - 'email' => mb_convert_case($user->email, MB_CASE_LOWER, 'UTF-8'), + 'email' => Str::lower($user->email), 'password' => $this->cleanPasswordHash($user->password), + 'profile_photo_path' => $this->fetchUserAvatar($curl, $user), + 'cover_photo_path' => $this->fetchUserCoverPhoto($curl, $user), 'created_at' => $this->cleanRegistrationDate($user->registrationDate), 'updated_at' => now('UTC')->toDateTimeString(), ]; @@ -224,6 +276,75 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue return str_starts_with($clean, '$2') ? $clean : ''; } + /** + * Fetch the user avatar from the Hub and store it anew. + */ + protected function fetchUserAvatar(CurlHandle $curl, object $user): string + { + // Fetch the user's avatar data from the temporary table. + $avatar = DB::table('temp_user_avatar')->where('userID', $user->userID)->first(); + + if (! $avatar) { + return ''; + } + + $hashShort = substr($avatar->fileHash, 0, 2); + $fileName = $avatar->fileHash.'.'.$avatar->avatarExtension; + $hubUrl = 'https://hub.sp-tarkov.com/images/avatars/'.$hashShort.'/'.$avatar->avatarID.'-'.$fileName; + $relativePath = 'user-avatars/'.$fileName; + + return $this->fetchAndStoreImage($curl, $hubUrl, $relativePath); + } + + /** + * Fetch and store an image from the Hub. + */ + protected function fetchAndStoreImage(CurlHandle $curl, string $hubUrl, string $relativePath): string + { + // Determine the disk to use based on the environment. + $disk = match (config('app.env')) { + 'production' => 'r2', // Cloudflare R2 Storage + default => 'public', // Local + }; + + // Check to make sure the image doesn't already exist. + if (Storage::disk($disk)->exists($relativePath)) { + return $relativePath; // Already exists, return the path. + } + + // Download the image using the cURL handler. + curl_setopt($curl, CURLOPT_URL, $hubUrl); + $image = curl_exec($curl); + + if ($image === false) { + Log::error('There was an error attempting to download the image. cURL error: '.curl_error($curl)); + + return ''; + } + + // Store the image on the disk. + Storage::disk($disk)->put($relativePath, $image); + + return $relativePath; + } + + /** + * Fetch the user avatar from the Hub and store it anew. + */ + protected function fetchUserCoverPhoto(CurlHandle $curl, object $user): string + { + if (empty($user->coverPhotoHash) || empty($user->coverPhotoExtension)) { + return ''; + } + + $hashShort = substr($user->coverPhotoHash, 0, 2); + $fileName = $user->coverPhotoHash.'.'.$user->coverPhotoExtension; + $hubUrl = 'https://hub.sp-tarkov.com/images/coverPhotos/'.$hashShort.'/'.$user->userID.'-'.$fileName; + $relativePath = 'user-covers/'.$fileName; + + return $this->fetchAndStoreImage($curl, $hubUrl, $relativePath); + } + /** * Clean the registration date from the Hub database. */ @@ -295,9 +416,6 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue } } - /* - * Build an array of user rank data ready to be inserted into the local database. - */ protected function collectUserRankData($user): ?array { if ($user->rankID && $user->rankTitle) { @@ -590,31 +708,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue $hubUrl = 'https://hub.sp-tarkov.com/files/images/file/'.$hashShort.'/'.$fileName; $relativePath = 'mods/'.$fileName; - // Determine the disk to use based on the environment. - $disk = match (config('app.env')) { - 'production' => 'r2', // Cloudflare R2 Storage - default => 'public', // Local - }; - - // Check to make sure the image doesn't already exist. - if (Storage::disk($disk)->exists($relativePath)) { - return $relativePath; // Already exists, return the path. - } - - // Download the image using the cURL handler. - curl_setopt($curl, CURLOPT_URL, $hubUrl); - $image = curl_exec($curl); - - if ($image === false) { - Log::error('There was an error attempting to download a mod thumbnail. cURL error: '.curl_error($curl)); - - return ''; - } - - // Store the image on the disk. - Storage::disk($disk)->put($relativePath, $image); - - return $relativePath; + return $this->fetchAndStoreImage($curl, $hubUrl, $relativePath); } /** @@ -693,6 +787,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue public function failed(Exception $exception): void { // Explicitly drop the temporary tables. + DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_user_avatar'); DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_author'); DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_option_values'); DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_content'); diff --git a/app/Models/User.php b/app/Models/User.php index b8982dd..26b0598 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -65,14 +65,6 @@ class User extends Authenticatable implements MustVerifyEmail return ! is_null($this->email_verified_at); } - /** - * The relationship between a user and their role. - */ - public function role(): BelongsTo - { - return $this->belongsTo(UserRole::class, 'user_role_id'); - } - /** * Check if the user has the role of a moderator. */ @@ -124,6 +116,24 @@ class User extends Authenticatable implements MustVerifyEmail return Str::lower(Str::slug($this->name)); } + /** + * Assign a role to the user. + */ + public function assignRole(UserRole $role): bool + { + $this->role()->associate($role); + + return $this->save(); + } + + /** + * The relationship between a user and their role. + */ + public function role(): BelongsTo + { + return $this->belongsTo(UserRole::class, 'user_role_id'); + } + /** * The attributes that should be cast to native types. */ diff --git a/config/horizon.php b/config/horizon.php index 6a28221..b3a3771 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -207,7 +207,7 @@ return [ 'maxJobs' => 0, 'memory' => 256, 'tries' => 1, - 'timeout' => 900, // 15 Minutes + 'timeout' => 1500, // 25 Minutes 'nice' => 0, ], ], diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 5a965db..5d26cb2 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -29,7 +29,8 @@ return new class extends Migration ->nullOnDelete() ->cascadeOnUpdate(); $table->rememberToken(); - $table->string('profile_photo_path', 2048)->nullable(); + $table->string('profile_photo_path', 2048)->nullable()->default(null); + $table->string('cover_photo_path', 2048)->nullable()->default(null); $table->timestamps(); }); From 35cd00e39d6898a23f0def4ac6fbf139ddd559d3 Mon Sep 17 00:00:00 2001 From: Refringe Date: Wed, 7 Aug 2024 16:23:55 -0400 Subject: [PATCH 05/15] Adds Cover Photo Field Adds the cover photo field to the Jetstream edit profile form. --- .../Fortify/UpdateUserProfileInformation.php | 5 ++ app/Livewire/Profile/UpdateProfileForm.php | 76 +++++++++++++++++++ app/Models/User.php | 21 +++-- app/Traits/HasCoverPhoto.php | 72 ++++++++++++++++++ config/filesystems.php | 12 +++ config/livewire.php | 14 ++-- resources/views/profile/show.blade.php | 2 +- .../update-profile-information-form.blade.php | 48 +++++++++++- resources/views/user/show.blade.php | 2 +- 9 files changed, 229 insertions(+), 23 deletions(-) create mode 100644 app/Livewire/Profile/UpdateProfileForm.php create mode 100644 app/Traits/HasCoverPhoto.php diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php index 9738772..10569dd 100644 --- a/app/Actions/Fortify/UpdateUserProfileInformation.php +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -21,12 +21,17 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)], 'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'], + 'cover' => ['nullable', 'mimes:jpg,jpeg,png', 'max:2048'], ])->validateWithBag('updateProfileInformation'); if (isset($input['photo'])) { $user->updateProfilePhoto($input['photo']); } + if (isset($input['cover'])) { + $user->updateCoverPhoto($input['cover']); + } + if ($input['email'] !== $user->email && $user instanceof MustVerifyEmail) { $this->updateVerifiedUser($user, $input); diff --git a/app/Livewire/Profile/UpdateProfileForm.php b/app/Livewire/Profile/UpdateProfileForm.php new file mode 100644 index 0000000..127e34f --- /dev/null +++ b/app/Livewire/Profile/UpdateProfileForm.php @@ -0,0 +1,76 @@ +validate([ + 'photo' => 'image|mimes:jpg,jpeg,png|max:1024', // 1MB Max + ]); + } + + /** + * When the cover is temporarily uploaded. + */ + public function updatedCover(): void + { + $this->validate([ + 'cover' => 'image|mimes:jpg,jpeg,png|max:2048', // 2MB Max + ]); + } + + /** + * Update the user's profile information. + */ + public function updateProfileInformation(UpdatesUserProfileInformation $updater): RedirectResponse|Redirector|null + { + $this->resetErrorBag(); + + $updater->update( + Auth::user(), + $this->photo || $this->cover + ? array_merge($this->state, array_filter([ + 'photo' => $this->photo, + 'cover' => $this->cover, + ])) : $this->state + ); + + if (isset($this->photo) || isset($this->cover)) { + return redirect()->route('profile.show'); + } + + $this->dispatch('saved'); + + $this->dispatch('refresh-navigation-menu'); + + return null; + } + + /** + * Delete user's profile photo. + */ + public function deleteCoverPhoto(): void + { + Auth::user()->deleteCoverPhoto(); + + $this->dispatch('refresh-navigation-menu'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 26b0598..bfbfa90 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Notifications\ResetPassword; use App\Notifications\VerifyEmail; +use App\Traits\HasCoverPhoto; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -21,6 +22,7 @@ class User extends Authenticatable implements MustVerifyEmail { use Bannable; use HasApiTokens; + use HasCoverPhoto; use HasFactory; use HasProfilePhoto; use Notifiable; @@ -134,6 +136,14 @@ class User extends Authenticatable implements MustVerifyEmail return $this->belongsTo(UserRole::class, 'user_role_id'); } + /** + * Get the disk that profile photos should be stored on. + */ + protected function profilePhotoDisk(): string + { + return config('filesystems.asset_upload', 'public'); + } + /** * The attributes that should be cast to native types. */ @@ -144,15 +154,4 @@ class User extends Authenticatable implements MustVerifyEmail 'password' => 'hashed', ]; } - - /** - * Get the disk that profile photos should be stored on. - */ - protected function profilePhotoDisk(): string - { - return match (config('app.env')) { - 'production' => 'r2', // Cloudflare R2 Storage - default => 'public', // Local - }; - } } diff --git a/app/Traits/HasCoverPhoto.php b/app/Traits/HasCoverPhoto.php new file mode 100644 index 0000000..ce429e3 --- /dev/null +++ b/app/Traits/HasCoverPhoto.php @@ -0,0 +1,72 @@ +cover_photo_path, function ($previous) use ($cover, $storagePath) { + $this->forceFill([ + 'cover_photo_path' => $cover->storePublicly( + $storagePath, ['disk' => $this->coverPhotoDisk()] + ), + ])->save(); + + if ($previous) { + Storage::disk($this->coverPhotoDisk())->delete($previous); + } + }); + } + + /** + * Get the disk that cover photos should be stored on. + */ + protected function coverPhotoDisk(): string + { + return config('filesystems.asset_upload', 'public'); + } + + /** + * Delete the user's cover photo. + */ + public function deleteCoverPhoto(): void + { + if (is_null($this->cover_photo_path)) { + return; + } + + Storage::disk($this->coverPhotoDisk())->delete($this->cover_photo_path); + + $this->forceFill([ + 'cover_photo_path' => null, + ])->save(); + } + + /** + * Get the URL to the user's cover photo. + */ + public function coverPhotoUrl(): Attribute + { + return Attribute::get(function (): string { + return $this->cover_photo_path + ? Storage::disk($this->coverPhotoDisk())->url($this->cover_photo_path) + : $this->defaultCoverPhotoUrl(); + }); + } + + /** + * Get the default profile photo URL if no profile photo has been uploaded. + */ + protected function defaultCoverPhotoUrl(): string + { + return 'https://picsum.photos/seed/'.urlencode($this->name).'/720/100?blur=2'; + } +} diff --git a/config/filesystems.php b/config/filesystems.php index f51b156..62d033d 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -15,6 +15,18 @@ return [ 'default' => env('FILESYSTEM_DISK', 'local'), + /* + |-------------------------------------------------------------------------- + | Default Asset Upload Disk + |-------------------------------------------------------------------------- + | + | Here you may specify the default filesystem disk that assets should be + | uploaded to. Typically, this will be either the "public" or "r2" disk. + | + */ + + 'asset_upload' => env('ASSET_UPLOAD_DISK', 'public'), + /* |-------------------------------------------------------------------------- | Filesystem Disks diff --git a/config/livewire.php b/config/livewire.php index 0d2ba89..c8bd1cd 100644 --- a/config/livewire.php +++ b/config/livewire.php @@ -64,17 +64,17 @@ return [ */ 'temporary_file_upload' => [ - 'disk' => null, // Example: 'local', 's3' | Default: 'default' - 'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB) - 'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp' - 'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1' - 'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs... + 'disk' => null, + 'rules' => ['file', 'max:12288'], + 'directory' => null, + 'middleware' => 'throttle:5,1', + 'preview_mimes' => [ 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4', 'mov', 'avi', 'wmv', 'mp3', 'm4a', 'jpg', 'jpeg', 'mpga', 'webp', 'wma', ], - 'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated... - 'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs... + 'max_upload_time' => 5, + 'cleanup' => true, ], /* diff --git a/resources/views/profile/show.blade.php b/resources/views/profile/show.blade.php index a089e25..76bbc77 100644 --- a/resources/views/profile/show.blade.php +++ b/resources/views/profile/show.blade.php @@ -8,7 +8,7 @@
@if (Laravel\Fortify\Features::canUpdateProfileInformation()) - @livewire('profile.update-profile-information-form') + @livewire('profile.update-profile-form') @endif diff --git a/resources/views/profile/update-profile-information-form.blade.php b/resources/views/profile/update-profile-information-form.blade.php index 57e560f..74b0f75 100644 --- a/resources/views/profile/update-profile-information-form.blade.php +++ b/resources/views/profile/update-profile-information-form.blade.php @@ -8,7 +8,7 @@ - + @if (Laravel\Jetstream\Jetstream::managesProfilePhotos())
@@ -24,7 +24,7 @@ reader.readAsDataURL($refs.photo.files[0]); " /> - +
@@ -52,6 +52,48 @@
@endif + +
+ + + + + + +
+ {{ $this->user->name }} +
+ + + + + + {{ __('Select A New Cover Photo') }} + + + @if ($this->user->cover_photo_path) + + {{ __('Remove Cover Photo') }} + + @endif + + +
+
@@ -88,7 +130,7 @@ {{ __('Saved.') }} - + {{ __('Save') }} diff --git a/resources/views/user/show.blade.php b/resources/views/user/show.blade.php index 943e592..8e2874c 100644 --- a/resources/views/user/show.blade.php +++ b/resources/views/user/show.blade.php @@ -2,7 +2,7 @@
- + {{ $user->name }}
From c2f1eed35c982d4f799df6b1b7fc52ab2c1366be Mon Sep 17 00:00:00 2001 From: Refringe Date: Wed, 7 Aug 2024 17:04:02 -0400 Subject: [PATCH 06/15] Adds link to profile in user navigation. --- resources/views/navigation-menu.blade.php | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/resources/views/navigation-menu.blade.php b/resources/views/navigation-menu.blade.php index fa16edf..b4b9757 100644 --- a/resources/views/navigation-menu.blade.php +++ b/resources/views/navigation-menu.blade.php @@ -63,18 +63,24 @@ {{ __('Dashboard') }} -
- +
+ + + {{ __('Edit Profile') }} + @if (Laravel\Jetstream\Jetstream::hasApiFeatures()) - From 0ed25fec039ea98b3ac0bc4a6ad2b4d863719f92 Mon Sep 17 00:00:00 2001 From: Refringe Date: Wed, 7 Aug 2024 23:30:09 -0400 Subject: [PATCH 07/15] User API Updates Adds user profile links to the user API resource. Fixes structure of relationship data and link sections. Adds parameter to include related user data when requesting mod data. --- app/Http/Controllers/Api/V0/ApiController.php | 22 ++++++++++++++ app/Http/Controllers/Api/V0/ModController.php | 3 +- .../Controllers/Api/V0/UsersController.php | 3 +- app/Http/Resources/Api/V0/ModResource.php | 29 ++++++++++++------- app/Http/Resources/Api/V0/UserResource.php | 6 ++-- 5 files changed, 46 insertions(+), 17 deletions(-) create mode 100644 app/Http/Controllers/Api/V0/ApiController.php diff --git a/app/Http/Controllers/Api/V0/ApiController.php b/app/Http/Controllers/Api/V0/ApiController.php new file mode 100644 index 0000000..e6c3955 --- /dev/null +++ b/app/Http/Controllers/Api/V0/ApiController.php @@ -0,0 +1,22 @@ +get('include'); + + if (! $param) { + return false; + } + + $includeValues = explode(',', Str::lower($param)); + + return in_array(Str::lower($relationship), $includeValues); + } +} diff --git a/app/Http/Controllers/Api/V0/ModController.php b/app/Http/Controllers/Api/V0/ModController.php index 2711ce1..1f69eb2 100644 --- a/app/Http/Controllers/Api/V0/ModController.php +++ b/app/Http/Controllers/Api/V0/ModController.php @@ -2,13 +2,12 @@ namespace App\Http\Controllers\Api\V0; -use App\Http\Controllers\Controller; use App\Http\Requests\Api\V0\StoreModRequest; use App\Http\Requests\Api\V0\UpdateModRequest; use App\Http\Resources\Api\V0\ModResource; use App\Models\Mod; -class ModController extends Controller +class ModController extends ApiController { /** * Display a listing of the resource. diff --git a/app/Http/Controllers/Api/V0/UsersController.php b/app/Http/Controllers/Api/V0/UsersController.php index d876348..a93f52c 100644 --- a/app/Http/Controllers/Api/V0/UsersController.php +++ b/app/Http/Controllers/Api/V0/UsersController.php @@ -2,13 +2,12 @@ namespace App\Http\Controllers\Api\V0; -use App\Http\Controllers\Controller; use App\Http\Requests\Api\V0\StoreUserRequest; use App\Http\Requests\Api\V0\UpdateUserRequest; use App\Http\Resources\Api\V0\UserResource; use App\Models\User; -class UsersController extends Controller +class UsersController extends ApiController { /** * Display a listing of the resource. diff --git a/app/Http/Resources/Api/V0/ModResource.php b/app/Http/Resources/Api/V0/ModResource.php index 42fd8d4..6b2d2e9 100644 --- a/app/Http/Resources/Api/V0/ModResource.php +++ b/app/Http/Resources/Api/V0/ModResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources\Api\V0; +use App\Http\Controllers\Api\V0\ApiController; use App\Models\Mod; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -35,23 +36,29 @@ class ModResource extends JsonResource 'published_at' => $this->published_at, ], 'relationships' => [ - 'users' => [ - 'data' => $this->users->map(fn ($user) => [ + 'users' => $this->users->map(fn ($user) => [ + 'data' => [ 'type' => 'user', 'id' => $user->id, - ])->toArray(), - - // TODO: Provide 'links.self' to user profile - //'links' => ['self' => '#'], - ], + ], + 'links' => [ + 'self' => $user->profileUrl(), + ], + ])->toArray(), 'license' => [ - 'data' => [ - 'type' => 'license', - 'id' => $this->license_id, + [ + 'data' => [ + 'type' => 'license', + 'id' => $this->license_id, + ], ], ], ], - 'included' => $this->users->map(fn ($user) => new UserResource($user)), + + 'includes' => $this->when( + ApiController::shouldInclude('users'), + fn () => $this->users->map(fn ($user) => new UserResource($user)) + ), // TODO: Provide 'included' data for attached 'license': //new LicenseResource($this->license) diff --git a/app/Http/Resources/Api/V0/UserResource.php b/app/Http/Resources/Api/V0/UserResource.php index f4e2e68..f4e63d1 100644 --- a/app/Http/Resources/Api/V0/UserResource.php +++ b/app/Http/Resources/Api/V0/UserResource.php @@ -30,11 +30,13 @@ class UserResource extends JsonResource ], ], ], + // TODO: Provide 'included' data for attached 'user_role' //'included' => [new UserRoleResource($this->role)], - // TODO: Provide 'links.self' to user profile: - //'links' => ['self' => '#'], + 'links' => [ + 'self' => $this->profileUrl(), + ], ]; } } From 3a334033fe2f4ab594cb70a342ed8ec411933d29 Mon Sep 17 00:00:00 2001 From: Refringe Date: Thu, 8 Aug 2024 16:11:50 -0400 Subject: [PATCH 08/15] API Updates Brings the API in close sync to the rest of the site. - Adds resources for License, UserRole, and ModVersion models - Adds filtering on attribute data - The `includes` data is now disabled by default and available conditionally --- app/Http/Controllers/Api/V0/ApiController.php | 18 ++- app/Http/Controllers/Api/V0/ModController.php | 5 +- .../Controllers/Api/V0/UsersController.php | 5 +- app/Http/Filters/V1/ModFilter.php | 116 ++++++++++++++++++ app/Http/Filters/V1/QueryFilter.php | 37 ++++++ app/Http/Filters/V1/UserFilter.php | 42 +++++++ app/Http/Resources/Api/V0/LicenseResource.php | 25 ++++ app/Http/Resources/Api/V0/ModResource.php | 29 +++-- .../Resources/Api/V0/ModVersionResource.php | 54 ++++++++ app/Http/Resources/Api/V0/UserResource.php | 13 +- .../Resources/Api/V0/UserRoleResource.php | 25 ++++ app/Models/Mod.php | 10 ++ app/Models/User.php | 10 ++ 13 files changed, 369 insertions(+), 20 deletions(-) create mode 100644 app/Http/Filters/V1/ModFilter.php create mode 100644 app/Http/Filters/V1/QueryFilter.php create mode 100644 app/Http/Filters/V1/UserFilter.php create mode 100644 app/Http/Resources/Api/V0/LicenseResource.php create mode 100644 app/Http/Resources/Api/V0/ModVersionResource.php create mode 100644 app/Http/Resources/Api/V0/UserRoleResource.php diff --git a/app/Http/Controllers/Api/V0/ApiController.php b/app/Http/Controllers/Api/V0/ApiController.php index e6c3955..588e047 100644 --- a/app/Http/Controllers/Api/V0/ApiController.php +++ b/app/Http/Controllers/Api/V0/ApiController.php @@ -7,7 +7,11 @@ use Illuminate\Support\Str; class ApiController extends Controller { - public static function shouldInclude(string $relationship): bool + /** + * Determine if the given relationship should be included in the request. If more than one relationship is provided, + * only one needs to be present in the request for this method to return true. + */ + public static function shouldInclude(string|array $relationships): bool { $param = request()->get('include'); @@ -17,6 +21,16 @@ class ApiController extends Controller $includeValues = explode(',', Str::lower($param)); - return in_array(Str::lower($relationship), $includeValues); + if (is_array($relationships)) { + foreach ($relationships as $relationship) { + if (in_array(Str::lower($relationship), $includeValues)) { + return true; + } + } + + return false; + } + + return in_array(Str::lower($relationships), $includeValues); } } diff --git a/app/Http/Controllers/Api/V0/ModController.php b/app/Http/Controllers/Api/V0/ModController.php index 1f69eb2..9065bbc 100644 --- a/app/Http/Controllers/Api/V0/ModController.php +++ b/app/Http/Controllers/Api/V0/ModController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\V0; +use App\Http\Filters\V1\ModFilter; use App\Http\Requests\Api\V0\StoreModRequest; use App\Http\Requests\Api\V0\UpdateModRequest; use App\Http\Resources\Api\V0\ModResource; @@ -12,9 +13,9 @@ class ModController extends ApiController /** * Display a listing of the resource. */ - public function index() + public function index(ModFilter $filters) { - return ModResource::collection(Mod::paginate()); + return ModResource::collection(Mod::filter($filters)->paginate()); } /** diff --git a/app/Http/Controllers/Api/V0/UsersController.php b/app/Http/Controllers/Api/V0/UsersController.php index a93f52c..8bf7824 100644 --- a/app/Http/Controllers/Api/V0/UsersController.php +++ b/app/Http/Controllers/Api/V0/UsersController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\V0; +use App\Http\Filters\V1\UserFilter; use App\Http\Requests\Api\V0\StoreUserRequest; use App\Http\Requests\Api\V0\UpdateUserRequest; use App\Http\Resources\Api\V0\UserResource; @@ -12,9 +13,9 @@ class UsersController extends ApiController /** * Display a listing of the resource. */ - public function index() + public function index(UserFilter $filters) { - return UserResource::collection(User::paginate()); + return UserResource::collection(User::filter($filters)->paginate()); } /** diff --git a/app/Http/Filters/V1/ModFilter.php b/app/Http/Filters/V1/ModFilter.php new file mode 100644 index 0000000..722ba71 --- /dev/null +++ b/app/Http/Filters/V1/ModFilter.php @@ -0,0 +1,116 @@ +builder->where('name', 'like', $like); + } + + public function slug(string $value): Builder + { + // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). + $like = Str::replace('*', '%', $value); + + return $this->builder->where('slug', 'like', $like); + } + + public function teaser(string $value): Builder + { + // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). + $like = Str::replace('*', '%', $value); + + return $this->builder->where('teaser', 'like', $like); + } + + public function source_code_link(string $value): Builder + { + // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). + $like = Str::replace('*', '%', $value); + + return $this->builder->where('source_code_link', 'like', $like); + } + + public function created_at(string $value): Builder + { + // The API allows for a range of dates to be passed as a comma-separated list. + $dates = explode(',', $value); + if (count($dates) > 1) { + return $this->builder->whereBetween('created_at', $dates); + } + + return $this->builder->whereDate('created_at', $value); + } + + public function updated_at(string $value): Builder + { + // The API allows for a range of dates to be passed as a comma-separated list. + $dates = explode(',', $value); + if (count($dates) > 1) { + return $this->builder->whereBetween('updated_at', $dates); + } + + return $this->builder->whereDate('updated_at', $value); + } + + public function published_at(string $value): Builder + { + // The API allows for a range of dates to be passed as a comma-separated list. + $dates = explode(',', $value); + if (count($dates) > 1) { + return $this->builder->whereBetween('published_at', $dates); + } + + return $this->builder->whereDate('published_at', $value); + } + + public function featured(string $value): Builder + { + // We need to convert the string user input to a boolean, or null if it's not a valid "truthy/falsy" value. + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + // This column is not nullable. + if ($value === null) { + return $this->builder; + } + + return $this->builder->where('featured', $value); + } + + public function contains_ads(string $value): Builder + { + // We need to convert the string user input to a boolean, or null if it's not a valid "truthy/falsy" value. + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + // This column is not nullable. + if ($value === null) { + return $this->builder; + } + + return $this->builder->where('contains_ads', $value); + } + + public function contains_ai_content(string $value): Builder + { + // We need to convert the string user input to a boolean, or null if it's not a valid "truthy/falsy" value. + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + // This column is not nullable. + if ($value === null) { + return $this->builder; + } + + return $this->builder->where('contains_ai_content', $value); + } +} diff --git a/app/Http/Filters/V1/QueryFilter.php b/app/Http/Filters/V1/QueryFilter.php new file mode 100644 index 0000000..0e4b8e6 --- /dev/null +++ b/app/Http/Filters/V1/QueryFilter.php @@ -0,0 +1,37 @@ +builder = $builder; + + foreach ($this->request->all() as $attribute => $value) { + if (method_exists($this, $attribute)) { + $this->$attribute($value); + } + } + + return $this->builder; + } + + protected function filter(array $filters): Builder + { + foreach ($filters as $attribute => $value) { + if (method_exists($this, $attribute)) { + $this->$attribute($value); + } + } + + return $this->builder; + } +} diff --git a/app/Http/Filters/V1/UserFilter.php b/app/Http/Filters/V1/UserFilter.php new file mode 100644 index 0000000..9684150 --- /dev/null +++ b/app/Http/Filters/V1/UserFilter.php @@ -0,0 +1,42 @@ +builder->where('name', 'like', $like); + } + + public function created_at(string $value): Builder + { + // The API allows for a range of dates to be passed as a comma-separated list. + $dates = explode(',', $value); + if (count($dates) > 1) { + return $this->builder->whereBetween('created_at', $dates); + } + + return $this->builder->whereDate('created_at', $value); + } + + public function updated_at(string $value): Builder + { + // The API allows for a range of dates to be passed as a comma-separated list. + $dates = explode(',', $value); + if (count($dates) > 1) { + return $this->builder->whereBetween('updated_at', $dates); + } + + return $this->builder->whereDate('updated_at', $value); + } +} diff --git a/app/Http/Resources/Api/V0/LicenseResource.php b/app/Http/Resources/Api/V0/LicenseResource.php new file mode 100644 index 0000000..a64ebe2 --- /dev/null +++ b/app/Http/Resources/Api/V0/LicenseResource.php @@ -0,0 +1,25 @@ + 'license', + 'id' => $this->id, + 'attributes' => [ + 'name' => $this->name, + 'link' => $this->link, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ], + ]; + } +} diff --git a/app/Http/Resources/Api/V0/ModResource.php b/app/Http/Resources/Api/V0/ModResource.php index 6b2d2e9..1737e26 100644 --- a/app/Http/Resources/Api/V0/ModResource.php +++ b/app/Http/Resources/Api/V0/ModResource.php @@ -45,6 +45,19 @@ class ModResource extends JsonResource 'self' => $user->profileUrl(), ], ])->toArray(), + 'versions' => $this->versions->map(fn ($version) => [ + 'data' => [ + 'type' => 'version', + 'id' => $version->id, + ], + + // TODO: The download link to the version can be placed here, but I'd like to track the number of + // downloads that are made, so we'll need a new route/feature for that. #35 + 'links' => [ + 'self' => $version->link, + ], + + ])->toArray(), 'license' => [ [ 'data' => [ @@ -54,15 +67,17 @@ class ModResource extends JsonResource ], ], ], - 'includes' => $this->when( - ApiController::shouldInclude('users'), - fn () => $this->users->map(fn ($user) => new UserResource($user)) + ApiController::shouldInclude(['users', 'license', 'versions']), + fn () => collect([ + 'users' => $this->users->map(fn ($user) => new UserResource($user)), + 'license' => new LicenseResource($this->license), + 'versions' => $this->versions->map(fn ($version) => new ModVersionResource($version)), + ]) + ->filter(fn ($value, $key) => ApiController::shouldInclude($key)) + ->flatten(1) + ->values() ), - - // TODO: Provide 'included' data for attached 'license': - //new LicenseResource($this->license) - 'links' => [ 'self' => route('mod.show', [ 'mod' => $this->id, diff --git a/app/Http/Resources/Api/V0/ModVersionResource.php b/app/Http/Resources/Api/V0/ModVersionResource.php new file mode 100644 index 0000000..d42008d --- /dev/null +++ b/app/Http/Resources/Api/V0/ModVersionResource.php @@ -0,0 +1,54 @@ + 'mod_version', + 'id' => $this->id, + 'attributes' => [ + 'hub_id' => $this->hub_id, + 'mod_id' => $this->mod_id, + 'version' => $this->version, + + // TODO: This should only be visible on the mod version show route(?) which doesn't exist. + //'description' => $this->when( + // $request->routeIs('api.v0.modversion.show'), + // $this->description + //), + + // TODO: The download link to the version can be placed here, but I'd like to track the number of + // downloads that are made, so we'll need a new route/feature for that. #35 + 'link' => $this->link, + + 'spt_version_id' => $this->spt_version_id, + 'virus_total_link' => $this->virus_total_link, + 'downloads' => $this->downloads, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + 'published_at' => $this->published_at, + ], + 'relationships' => [ + 'spt_version' => [ + [ + 'data' => [ + 'type' => 'spt_version', + 'id' => $this->spt_version_id, + ], + ], + ], + ], + ]; + } +} diff --git a/app/Http/Resources/Api/V0/UserResource.php b/app/Http/Resources/Api/V0/UserResource.php index f4e63d1..903012f 100644 --- a/app/Http/Resources/Api/V0/UserResource.php +++ b/app/Http/Resources/Api/V0/UserResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources\Api\V0; +use App\Http\Controllers\Api\V0\ApiController; use App\Models\User; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -9,9 +10,6 @@ use Illuminate\Http\Resources\Json\JsonResource; /** @mixin User */ class UserResource extends JsonResource { - /** - * Transform the resource into an array. - */ public function toArray(Request $request): array { return [ @@ -21,6 +19,7 @@ class UserResource extends JsonResource 'name' => $this->name, 'user_role_id' => $this->user_role_id, 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, ], 'relationships' => [ 'user_role' => [ @@ -30,10 +29,10 @@ class UserResource extends JsonResource ], ], ], - - // TODO: Provide 'included' data for attached 'user_role' - //'included' => [new UserRoleResource($this->role)], - + 'includes' => $this->when( + ApiController::shouldInclude('user_role'), + new UserRoleResource($this->role) + ), 'links' => [ 'self' => $this->profileUrl(), ], diff --git a/app/Http/Resources/Api/V0/UserRoleResource.php b/app/Http/Resources/Api/V0/UserRoleResource.php new file mode 100644 index 0000000..51d5146 --- /dev/null +++ b/app/Http/Resources/Api/V0/UserRoleResource.php @@ -0,0 +1,25 @@ + 'user_role', + 'id' => $this->id, + 'attributes' => [ + 'name' => $this->name, + 'short_name' => $this->short_name, + 'description' => $this->description, + 'color_class' => $this->color_class, + ], + ]; + } +} diff --git a/app/Models/Mod.php b/app/Models/Mod.php index 8a0e512..193cc17 100644 --- a/app/Models/Mod.php +++ b/app/Models/Mod.php @@ -2,8 +2,10 @@ namespace App\Models; +use App\Http\Filters\V1\QueryFilter; use App\Models\Scopes\DisabledScope; use App\Models\Scopes\PublishedScope; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -144,6 +146,14 @@ class Mod extends Model }; } + /** + * Scope a query by applying QueryFilter filters. + */ + public function scopeFilter(Builder $builder, QueryFilter $filters): Builder + { + return $filters->apply($builder); + } + /** * The attributes that should be cast to native types. */ diff --git a/app/Models/User.php b/app/Models/User.php index bfbfa90..157248b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,10 +2,12 @@ namespace App\Models; +use App\Http\Filters\V1\QueryFilter; use App\Notifications\ResetPassword; use App\Notifications\VerifyEmail; use App\Traits\HasCoverPhoto; use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -136,6 +138,14 @@ class User extends Authenticatable implements MustVerifyEmail return $this->belongsTo(UserRole::class, 'user_role_id'); } + /** + * Scope a query by applying QueryFilter filters. + */ + public function scopeFilter(Builder $builder, QueryFilter $filters): Builder + { + return $filters->apply($builder); + } + /** * Get the disk that profile photos should be stored on. */ From 23a9cabf998c907c7e94755181df67db1fae418f Mon Sep 17 00:00:00 2001 From: Refringe Date: Thu, 8 Aug 2024 18:18:05 -0400 Subject: [PATCH 09/15] Implements API Sorting You can now sort mod and user data by whitelisted attributes. --- app/Http/Filters/V1/ModFilter.php | 33 ++++++++++++++++++++--- app/Http/Filters/V1/QueryFilter.php | 26 +++++++++++++++++- app/Http/Filters/V1/UserFilter.php | 17 ++++++++++-- app/Http/Resources/Api/V0/ModResource.php | 1 + 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/app/Http/Filters/V1/ModFilter.php b/app/Http/Filters/V1/ModFilter.php index 722ba71..dd64b11 100644 --- a/app/Http/Filters/V1/ModFilter.php +++ b/app/Http/Filters/V1/ModFilter.php @@ -7,9 +7,36 @@ use Illuminate\Support\Str; class ModFilter extends QueryFilter { + protected array $sortable = [ + 'name', + 'slug', + 'teaser', + 'source_code_link', + 'featured', + 'contains_ads', + 'contains_ai_content', + 'created_at', + 'updated_at', + 'published_at', + ]; + // TODO: Many of these are repeated across UserFilter and ModFilter. Consider refactoring into a shared trait. // Also, consider using common filter types and making the field names dynamic. + public function id(string $value): Builder + { + $ids = array_map('trim', explode(',', $value)); + + return $this->builder->whereIn('id', $ids); + } + + public function hub_id(string $value): Builder + { + $ids = array_map('trim', explode(',', $value)); + + return $this->builder->whereIn('hub_id', $ids); + } + public function name(string $value): Builder { // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). @@ -45,7 +72,7 @@ class ModFilter extends QueryFilter public function created_at(string $value): Builder { // The API allows for a range of dates to be passed as a comma-separated list. - $dates = explode(',', $value); + $dates = array_map('trim', explode(',', $value)); if (count($dates) > 1) { return $this->builder->whereBetween('created_at', $dates); } @@ -56,7 +83,7 @@ class ModFilter extends QueryFilter public function updated_at(string $value): Builder { // The API allows for a range of dates to be passed as a comma-separated list. - $dates = explode(',', $value); + $dates = array_map('trim', explode(',', $value)); if (count($dates) > 1) { return $this->builder->whereBetween('updated_at', $dates); } @@ -67,7 +94,7 @@ class ModFilter extends QueryFilter public function published_at(string $value): Builder { // The API allows for a range of dates to be passed as a comma-separated list. - $dates = explode(',', $value); + $dates = array_map('trim', explode(',', $value)); if (count($dates) > 1) { return $this->builder->whereBetween('published_at', $dates); } diff --git a/app/Http/Filters/V1/QueryFilter.php b/app/Http/Filters/V1/QueryFilter.php index 0e4b8e6..f636cd7 100644 --- a/app/Http/Filters/V1/QueryFilter.php +++ b/app/Http/Filters/V1/QueryFilter.php @@ -4,12 +4,20 @@ namespace App\Http\Filters\V1; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; +use Illuminate\Support\Str; abstract class QueryFilter { protected Builder $builder; - public function __construct(protected Request $request) {} + protected Request $request; + + protected array $sortable = []; + + public function __construct(Request $request) + { + $this->request = $request; + } public function apply(Builder $builder): Builder { @@ -34,4 +42,20 @@ abstract class QueryFilter return $this->builder; } + + protected function sort(string $values): Builder + { + $sortables = array_map('trim', explode(',', $values)); + + foreach ($sortables as $sortable) { + $direction = Str::startsWith($sortable, '-') ? 'desc' : 'asc'; + $column = Str::of($sortable)->remove('-')->value(); + + if (in_array($column, $this->sortable)) { + $this->builder->orderBy($column, $direction); + } + } + + return $this->builder; + } } diff --git a/app/Http/Filters/V1/UserFilter.php b/app/Http/Filters/V1/UserFilter.php index 9684150..1eec8fc 100644 --- a/app/Http/Filters/V1/UserFilter.php +++ b/app/Http/Filters/V1/UserFilter.php @@ -7,9 +7,22 @@ use Illuminate\Support\Str; class UserFilter extends QueryFilter { + protected array $sortable = [ + 'name', + 'created_at', + 'updated_at', + ]; + // TODO: Many of these are repeated across UserFilter and ModFilter. Consider refactoring into a shared trait. // Also, consider using common filter types and making the field names dynamic. + public function id(string $value): Builder + { + $ids = array_map('trim', explode(',', $value)); + + return $this->builder->whereIn('id', $ids); + } + public function name(string $value): Builder { // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). @@ -21,7 +34,7 @@ class UserFilter extends QueryFilter public function created_at(string $value): Builder { // The API allows for a range of dates to be passed as a comma-separated list. - $dates = explode(',', $value); + $dates = array_map('trim', explode(',', $value)); if (count($dates) > 1) { return $this->builder->whereBetween('created_at', $dates); } @@ -32,7 +45,7 @@ class UserFilter extends QueryFilter public function updated_at(string $value): Builder { // The API allows for a range of dates to be passed as a comma-separated list. - $dates = explode(',', $value); + $dates = array_map('trim', explode(',', $value)); if (count($dates) > 1) { return $this->builder->whereBetween('updated_at', $dates); } diff --git a/app/Http/Resources/Api/V0/ModResource.php b/app/Http/Resources/Api/V0/ModResource.php index 1737e26..d02762d 100644 --- a/app/Http/Resources/Api/V0/ModResource.php +++ b/app/Http/Resources/Api/V0/ModResource.php @@ -19,6 +19,7 @@ class ModResource extends JsonResource 'type' => 'mod', 'id' => $this->id, 'attributes' => [ + 'hub_id' => $this->hub_id, 'name' => $this->name, 'slug' => $this->slug, 'teaser' => $this->teaser, From ecc2d1ca1a073c434f8c73da71eaf8cba7c4c8a6 Mon Sep 17 00:00:00 2001 From: Refringe Date: Thu, 8 Aug 2024 23:02:48 -0400 Subject: [PATCH 10/15] Mod Detail Page Main Card Cleans up the main card on the mod detail page to make the SPT version more in-sync with the rest of the site. Resolves #28 --- database/factories/SptVersionFactory.php | 2 +- resources/views/mod/show.blade.php | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/database/factories/SptVersionFactory.php b/database/factories/SptVersionFactory.php index e25d046..a891069 100644 --- a/database/factories/SptVersionFactory.php +++ b/database/factories/SptVersionFactory.php @@ -13,7 +13,7 @@ class SptVersionFactory extends Factory public function definition(): array { return [ - 'version' => $this->faker->numerify('1.#.#'), + 'version' => $this->faker->numerify('SPT 1.#.#'), 'color_class' => $this->faker->randomElement(['red', 'green', 'emerald', 'lime', 'yellow', 'grey']), 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), diff --git a/resources/views/mod/show.blade.php b/resources/views/mod/show.blade.php index 00ae244..8451457 100644 --- a/resources/views/mod/show.blade.php +++ b/resources/views/mod/show.blade.php @@ -6,7 +6,7 @@ -
+
{{-- Main Mod Details Card --}} @@ -22,15 +22,12 @@
-

+

{{ $mod->name }} {{ $latestVersion->version }}

- - {{ $latestVersion->sptVersion->version }} -

{{ __('Created by') }} @@ -38,8 +35,12 @@ {{ $user->name }}{{ $loop->last ? '' : ',' }} @endforeach

-

{{ $latestVersion->sptVersion->version }} {{ __('Compatible') }}

{{ Number::format($mod->total_downloads) }} {{ __('Downloads') }}

+

+ + {{ $latestVersion->sptVersion->version }} {{ __('Compatible') }} + +

From 0978aa14b993de321b7c9a0f8791e045ab490c75 Mon Sep 17 00:00:00 2001 From: Refringe Date: Thu, 8 Aug 2024 23:19:23 -0400 Subject: [PATCH 11/15] Adds Social Links Social links to our Discord server and our Subreddit have been added to the footer. Resolves #30 --- resources/views/components/footer.blade.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/resources/views/components/footer.blade.php b/resources/views/components/footer.blade.php index a642506..fdf2261 100644 --- a/resources/views/components/footer.blade.php +++ b/resources/views/components/footer.blade.php @@ -4,6 +4,18 @@

The Forge

+

+ + + + + + + + + + +

From d247b8cbe699110ff59c9349072596a4a4540576 Mon Sep 17 00:00:00 2001 From: Refringe Date: Thu, 8 Aug 2024 23:46:24 -0400 Subject: [PATCH 12/15] Resolves CSS Link/Container Issue This resolves an issue with the mod cards where the anchor tag was larger than the containing div on smaller breakpoints. Resolves #21 --- resources/views/components/mod-list.blade.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/views/components/mod-list.blade.php b/resources/views/components/mod-list.blade.php index fe56f71..1b628ab 100644 --- a/resources/views/components/mod-list.blade.php +++ b/resources/views/components/mod-list.blade.php @@ -2,11 +2,11 @@
@foreach ($mods as $mod) - -
+ +
- @if(empty($mod->thumbnail)) + @if (empty($mod->thumbnail)) {{ $mod->name }} @else From 40884ae1c4d4d30a5c16f01e7e132093eb8eae04 Mon Sep 17 00:00:00 2001 From: Refringe Date: Thu, 8 Aug 2024 23:54:43 -0400 Subject: [PATCH 13/15] Mobile Download Button Location Moves the mobile download button to above the tabs. Resolves #23 --- resources/views/mod/show.blade.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/resources/views/mod/show.blade.php b/resources/views/mod/show.blade.php index 8451457..50988d4 100644 --- a/resources/views/mod/show.blade.php +++ b/resources/views/mod/show.blade.php @@ -48,6 +48,11 @@ {{-- Tabs --}}
+ {{-- Mobile Download Button --}} + + + + {{-- Mobile Dropdown --}}
@@ -133,8 +138,8 @@ {{-- Right Column --}}
- {{-- Main Download Button --}} - + {{-- Desktop Download Button --}} + From 3aefae4bb802e8cbc4c98dc0bc39e9dc5981b960 Mon Sep 17 00:00:00 2001 From: Refringe Date: Fri, 9 Aug 2024 00:11:32 -0400 Subject: [PATCH 14/15] Resolves Mod Dependancy Display Error Fixes a display error when a mod dependancy is created, but can not be resolved into an actual available version. --- resources/views/mod/show.blade.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/resources/views/mod/show.blade.php b/resources/views/mod/show.blade.php index 50988d4..2e3a078 100644 --- a/resources/views/mod/show.blade.php +++ b/resources/views/mod/show.blade.php @@ -109,13 +109,15 @@ {{ __('Created') }} {{ $version->created_at->format("M d, h:m a") }} {{ __('Updated') }} {{ $version->updated_at->format("M d, h:m a") }}
- @if ($version->dependencies->count()) + @if ($version->dependencies->isNotEmpty() && $version->dependencies->contains(fn($dependency) => $dependency->resolvedVersion?->mod))
{{ __('Dependencies:') }} @foreach ($version->dependencies as $dependency) - - {{ $dependency->resolvedVersion->mod->name }} ({{ $dependency->resolvedVersion->version }}) - @if (!$loop->last), @endif + @if ($dependency->resolvedVersion?->mod) + + {{ $dependency->resolvedVersion->mod->name }} ({{ $dependency->resolvedVersion->version }}) + @if (!$loop->last), @endif + @endif @endforeach
@endif @@ -177,7 +179,7 @@

@endif - @if ($latestVersion->dependencies->count()) + @if ($latestVersion->dependencies->isNotEmpty() && $latestVersion->dependencies->contains(fn($dependency) => $dependency->resolvedVersion?->mod))
  • {{ __('Latest Version Dependencies') }}

    From 713ea7e076b128835c85caefe403b84d612e53ac Mon Sep 17 00:00:00 2001 From: Refringe Date: Fri, 9 Aug 2024 00:35:18 -0400 Subject: [PATCH 15/15] Download Number Macro Adds a `Number:downloads()` macro to format the number of downloads depending on how many there are. Example: 1259000 is converted into 1.25M, 125900 is converted into 125.9K. Updated the views to use this macro. Also included a title tag with the exact number so they can be viewed on hover. --- app/Providers/AppServiceProvider.php | 11 +++++++++++ resources/views/components/mod-list-stats.blade.php | 2 +- resources/views/mod/show.blade.php | 4 ++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 86eacd5..f6f6e91 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -9,6 +9,7 @@ use App\Observers\ModDependencyObserver; use App\Observers\ModVersionObserver; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Number; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -37,5 +38,15 @@ class AppServiceProvider extends ServiceProvider Gate::define('viewPulse', function (User $user) { return $user->isAdmin(); }); + + // Register a number macro to format download numbers. + Number::macro('downloads', function (int|float $number) { + return Number::forHumans( + $number, + $number > 1000000 ? 2 : ($number > 1000 ? 1 : 0), + maxPrecision: null, + abbreviate: true + ); + }); } } diff --git a/resources/views/components/mod-list-stats.blade.php b/resources/views/components/mod-list-stats.blade.php index 456552d..babb578 100644 --- a/resources/views/components/mod-list-stats.blade.php +++ b/resources/views/components/mod-list-stats.blade.php @@ -1,5 +1,5 @@

    class(['text-slate-700 dark:text-gray-300 text-sm']) }}> - {{ Number::format($mod->total_downloads) }} downloads + {{ Number::downloads($mod->total_downloads) }} downloads @if(!is_null($mod->created_at)) — Created diff --git a/resources/views/mod/show.blade.php b/resources/views/mod/show.blade.php index 2e3a078..ef5ab29 100644 --- a/resources/views/mod/show.blade.php +++ b/resources/views/mod/show.blade.php @@ -35,7 +35,7 @@ {{ $user->name }}{{ $loop->last ? '' : ',' }} @endforeach

    -

    {{ Number::format($mod->total_downloads) }} {{ __('Downloads') }}

    +

    {{ Number::downloads($mod->total_downloads) }} {{ __('Downloads') }}

    {{ $latestVersion->sptVersion->version }} {{ __('Compatible') }} @@ -97,7 +97,7 @@ {{ __('Version') }} {{ $version->version }} -

    {{ Number::forhumans($version->downloads) }} {{ __('Downloads') }}

    +

    {{ Number::downloads($version->downloads) }} {{ __('Downloads') }}