diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php index 10f3d224..95ddcfae 100644 --- a/app/Http/Controllers/Settings/ProfileController.php +++ b/app/Http/Controllers/Settings/ProfileController.php @@ -8,6 +8,7 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Storage; use Inertia\Inertia; use Inertia\Response; @@ -15,6 +16,9 @@ class ProfileController extends Controller { /** * Show the user's profile settings page. + * + * @param \Illuminate\Http\Request $request + * @return \Inertia\Response */ public function edit(Request $request): Response { @@ -26,22 +30,43 @@ public function edit(Request $request): Response /** * Update the user's profile information. + * + * @param \App\Http\Requests\Settings\ProfileUpdateRequest $request + * @return \Illuminate\Http\RedirectResponse */ public function update(ProfileUpdateRequest $request): RedirectResponse { - $request->user()->fill($request->validated()); + $user = $request->user(); + + $user->fill($request->validated()); + + if ($user->isDirty('email')) { + $user->email_verified_at = null; + } + + if ($request->boolean('remove_avatar')) { + if ($user->profile_photo_path && Storage::disk('public')->exists($user->profile_photo_path)) { + Storage::disk('public')->delete($user->profile_photo_path); + } + + $user->profile_photo_path = null; + } - if ($request->user()->isDirty('email')) { - $request->user()->email_verified_at = null; + if ($request->hasFile('profile_photo')) { + $path = $request->file('profile_photo')->store('avatars', 'public'); + $user->profile_photo_path = $path; } - $request->user()->save(); + $user->save(); return to_route('profile.edit'); } /** - * Delete the user's profile. + * Delete the user's account. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\RedirectResponse */ public function destroy(Request $request): RedirectResponse { diff --git a/app/Http/Requests/Settings/ProfileUpdateRequest.php b/app/Http/Requests/Settings/ProfileUpdateRequest.php index c294aab2..330b3fbf 100644 --- a/app/Http/Requests/Settings/ProfileUpdateRequest.php +++ b/app/Http/Requests/Settings/ProfileUpdateRequest.php @@ -25,6 +25,8 @@ public function rules(): array 'max:255', Rule::unique(User::class)->ignore($this->user()->id), ], + 'profile_photo' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'], + 'remove_avatar' => ['nullable', 'boolean'] ]; } } diff --git a/app/Models/Traits/HasProfilePhoto.php b/app/Models/Traits/HasProfilePhoto.php new file mode 100644 index 00000000..4ddb5f1a --- /dev/null +++ b/app/Models/Traits/HasProfilePhoto.php @@ -0,0 +1,27 @@ +profile_photo_path + ? Storage::url($this->profile_photo_path) + : null; + } + + /** + * Get the avatar alias to the profile photo URL. + */ + public function getAvatarAttribute(): ?string + { + return $this->profile_photo_url; + } + +} diff --git a/app/Models/User.php b/app/Models/User.php index 749c7b77..5e9a763d 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,6 +3,7 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Models\Traits\HasProfilePhoto; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -10,7 +11,7 @@ class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasFactory, Notifiable, HasProfilePhoto; /** * The attributes that are mass assignable. @@ -21,6 +22,7 @@ class User extends Authenticatable 'name', 'email', 'password', + 'profile_photo_path', ]; /** @@ -31,6 +33,16 @@ class User extends Authenticatable protected $hidden = [ 'password', 'remember_token', + 'profile_photo_path', + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $appends = [ + 'avatar', ]; /** diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 584104c9..8fa4ea47 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -29,6 +29,9 @@ public function definition(): array 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), + 'profile_photo_path' => null, + //or use some fake ones like + //'profile_photo_path' => 'https://i.pravatar.cc/150?img=' . fake()->numberBetween(1, 70) ]; } @@ -37,7 +40,7 @@ public function definition(): array */ public function unverified(): static { - return $this->state(fn (array $attributes) => [ + return $this->state(fn(array $attributes) => [ 'email_verified_at' => null, ]); } 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 05fb5d9e..00f8d164 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -4,8 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration -{ +return new class extends Migration { /** * Run the migrations. */ @@ -17,6 +16,7 @@ public function up(): void $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); $table->string('password'); + $table->string('profile_photo_path', 2048)->nullable(); $table->rememberToken(); $table->timestamps(); }); diff --git a/resources/js/components/NavMain.vue b/resources/js/components/NavMain.vue index 8aa7d240..7b9020e4 100644 --- a/resources/js/components/NavMain.vue +++ b/resources/js/components/NavMain.vue @@ -15,10 +15,7 @@ const page = usePage(); Platform - + {{ item.title }} diff --git a/resources/js/components/NavUser.vue b/resources/js/components/NavUser.vue index 65dff773..6c54c01b 100644 --- a/resources/js/components/NavUser.vue +++ b/resources/js/components/NavUser.vue @@ -22,10 +22,10 @@ const { isMobile, state } = useSidebar(); - diff --git a/resources/js/pages/settings/Profile.vue b/resources/js/pages/settings/Profile.vue index b198b60b..45ac4510 100644 --- a/resources/js/pages/settings/Profile.vue +++ b/resources/js/pages/settings/Profile.vue @@ -4,12 +4,28 @@ import { Head, Link, useForm, usePage } from '@inertiajs/vue3'; import DeleteUser from '@/components/DeleteUser.vue'; import HeadingSmall from '@/components/HeadingSmall.vue'; import InputError from '@/components/InputError.vue'; +import Avatar from '@/components/ui/avatar/Avatar.vue'; +import AvatarFallback from '@/components/ui/avatar/AvatarFallback.vue'; +import AvatarImage from '@/components/ui/avatar/AvatarImage.vue'; import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { useInitials } from '@/composables/useInitials'; import AppLayout from '@/layouts/AppLayout.vue'; import SettingsLayout from '@/layouts/settings/Layout.vue'; import { type BreadcrumbItem, type SharedData, type User } from '@/types'; +import { Trash2 } from 'lucide-vue-next'; +import { computed } from 'vue'; interface Props { mustVerifyEmail: boolean; @@ -28,27 +44,110 @@ const breadcrumbs: BreadcrumbItem[] = [ const page = usePage(); const user = page.props.auth.user as User; +const { getInitials } = useInitials(); + const form = useForm({ name: user.name, email: user.email, + profile_photo: null as File | null, + remove_avatar: false as boolean, + _method: 'PATCH', }); +const handleAvatarChange = (event: Event) => { + const target = event.target as HTMLInputElement; + if (target.files && target.files[0]) { + const file = target.files[0]; + if (file.size > 2 * 1024 * 1024) { + form.errors.profile_photo = 'File size exceeds 2MB.'; + return; + } + form.profile_photo = file; + } +}; + +const removeAvatar = () => { + form.profile_photo = null; + user.avatar = undefined; + form.remove_avatar = true; + form.errors.profile_photo = ''; +}; + const submit = () => { - form.patch(route('profile.update'), { + form.post(route('profile.update'), { preserveScroll: true, + forceFormData: true, }); }; + +const avatarSrc = computed(() => { + if (form.profile_photo instanceof File) { + return URL.createObjectURL(form.profile_photo); + } + return form.profile_photo === null ? null : user.avatar; +});