Adds Cover Photo Field

Adds the cover photo field to the Jetstream edit profile form.
This commit is contained in:
Refringe 2024-08-07 16:23:55 -04:00
parent 55273e5a90
commit 35cd00e39d
Signed by: Refringe
SSH Key Fingerprint: SHA256:t865XsQpfTeqPRBMN2G6+N8wlDjkgUCZF3WGW6O9N/k
9 changed files with 229 additions and 23 deletions

View File

@ -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);

View File

@ -0,0 +1,76 @@
<?php
namespace App\Livewire\Profile;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
use Laravel\Jetstream\Http\Livewire\UpdateProfileInformationForm;
use Livewire\Features\SupportRedirects\Redirector;
class UpdateProfileForm extends UpdateProfileInformationForm
{
/**
* The new cover photo for the user.
*
* @var mixed
*/
public $cover;
/**
* When the photo is temporarily uploaded.
*/
public function updatedPhoto(): void
{
$this->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');
}
}

View File

@ -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
};
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Traits;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
trait HasCoverPhoto
{
/**
* Update the user's cover photo.
*/
public function updateCoverPhoto(UploadedFile $cover, $storagePath = 'cover-photos'): void
{
tap($this->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';
}
}

View File

@ -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

View File

@ -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,
],
/*

View File

@ -8,7 +8,7 @@
<div>
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
@if (Laravel\Fortify\Features::canUpdateProfileInformation())
@livewire('profile.update-profile-information-form')
@livewire('profile.update-profile-form')
<x-section-border />
@endif

View File

@ -8,7 +8,7 @@
</x-slot>
<x-slot name="form">
<!-- Profile Photo -->
<!-- Profile Picture -->
@if (Laravel\Jetstream\Jetstream::managesProfilePhotos())
<div x-data="{photoName: null, photoPreview: null}" class="col-span-6 sm:col-span-4">
<!-- Profile Photo File Input -->
@ -24,7 +24,7 @@
reader.readAsDataURL($refs.photo.files[0]);
" />
<x-label for="photo" value="{{ __('Photo') }}" />
<x-label for="photo" value="{{ __('Profile Picture') }}" />
<!-- Current Profile Photo -->
<div class="mt-2" x-show="! photoPreview">
@ -52,6 +52,48 @@
</div>
@endif
<!-- Cover Picture -->
<div x-data="{coverName: null, coverPreview: null}" class="col-span-6 sm:col-span-4">
<!-- Cover Picture File Input -->
<input type="file" id="cover" class="hidden"
wire:model.live="cover"
x-ref="cover"
x-on:change="
coverName = $refs.cover.files[0].name;
const reader = new FileReader();
reader.onload = (e) => {
coverPreview = e.target.result;
};
reader.readAsDataURL($refs.cover.files[0]);
" />
<x-label for="cover" value="{{ __('Cover Picture') }}" />
<!-- Current Cover Photo -->
<div class="mt-2" x-show="! coverPreview">
<img src="{{ $this->user->cover_photo_url }}" alt="{{ $this->user->name }}" class="rounded-sm h-20 w-60 object-cover">
</div>
<!-- New Cover Photo Preview -->
<div class="mt-2" x-show="coverPreview" style="display: none;">
<span class="block h-20 w-60 bg-cover bg-no-repeat bg-center"
x-bind:style="'background-image: url(\'' + coverPreview + '\');'">
</span>
</div>
<x-secondary-button class="mt-2 me-2" type="button" x-on:click.prevent="$refs.cover.click()">
{{ __('Select A New Cover Photo') }}
</x-secondary-button>
@if ($this->user->cover_photo_path)
<x-secondary-button type="button" class="mt-2" wire:click="deleteCoverPhoto">
{{ __('Remove Cover Photo') }}
</x-secondary-button>
@endif
<x-input-error for="cover" class="mt-2" />
</div>
<!-- Name -->
<div class="col-span-6 sm:col-span-4">
<x-label for="name" value="{{ __('Name') }}" />
@ -88,7 +130,7 @@
{{ __('Saved.') }}
</x-action-message>
<x-button wire:loading.attr="disabled" wire:target="photo">
<x-button wire:loading.attr="disabled" wire:target="photo,cover">
{{ __('Save') }}
</x-button>
</x-slot>

View File

@ -2,7 +2,7 @@
<div class="sm:-mt-12 dark:bg-gray-800 dark:text-gray-100">
<div>
<img class="h-32 w-full object-cover lg:h-48" src="https://images.unsplash.com/photo-1444628838545-ac4016a5418a?ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1&auto=format&fit=crop&w=1950&q=80" alt="">
<img class="h-32 w-full object-cover lg:h-48" src="{{ $user->cover_photo_url }}" alt="{{ $user->name }}">
</div>
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
<div class="-mt-12 sm:-mt-16 sm:flex sm:items-end sm:space-x-5">