From 115f81fe960bad15fc97f117dff6ef447e03af53 Mon Sep 17 00:00:00 2001 From: Refringe Date: Sat, 20 Jul 2024 13:28:38 -0400 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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(), + ], ]; } }