Skip to content

Commit 630bee5

Browse files
committed
feat: add profile images
1 parent 4d1d2c2 commit 630bee5

File tree

8 files changed

+222
-18
lines changed

8 files changed

+222
-18
lines changed

app/Http/Controllers/Settings/ProfileController.php

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
use Illuminate\Contracts\Auth\MustVerifyEmail;
1111
use Illuminate\Http\RedirectResponse;
1212
use Illuminate\Http\Request;
13+
use Illuminate\Http\UploadedFile;
1314
use Illuminate\Support\Facades\Auth;
15+
use Illuminate\Support\Facades\Storage;
1416
use Inertia\Inertia;
1517
use Inertia\Response;
1618

@@ -34,16 +36,30 @@ public function update(ProfileUpdateRequest $request): RedirectResponse
3436
{
3537
/** @var User $user */
3638
$user = $request->user();
37-
38-
/** @var mixed[] $validated */
39-
$validated = $request->validated();
40-
41-
$user->fill($validated);
39+
$user->fill([
40+
'first_name' => $request->string('first_name')->toString(),
41+
'last_name' => $request->string('last_name')->toString(),
42+
'email' => $request->string('email')->toString(),
43+
]);
4244

4345
if ($user->isDirty('email')) {
4446
$user->email_verified_at = null;
4547
}
4648

49+
if ($request->hasFile('profile_image')) {
50+
if ($user->avatar !== null) {
51+
Storage::disk('public')->delete($user->avatar);
52+
}
53+
54+
/** @var UploadedFile $file */
55+
$file = $request->file('profile_image');
56+
$avatar = $file->store('avatars', 'public');
57+
58+
if ($avatar !== false) {
59+
$user->avatar = $avatar;
60+
}
61+
}
62+
4763
$user->save();
4864

4965
return to_route('profile.edit');
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Controllers\Settings;
6+
7+
use App\Models\User;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Support\Facades\Storage;
11+
12+
final class ProfilePhotoController
13+
{
14+
/**
15+
* Delete the current user's profile photo.
16+
*/
17+
public function destroy(Request $request): RedirectResponse
18+
{
19+
/** @var User $user */
20+
$user = $request->user();
21+
$avatarPath = $user->avatar;
22+
23+
if ($avatarPath !== null && Storage::disk('public')->exists($avatarPath)) {
24+
Storage::disk('public')->delete($avatarPath);
25+
}
26+
27+
$user->avatar = null;
28+
$user->save();
29+
30+
return to_route('profile.edit');
31+
}
32+
}

app/Models/User.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,13 @@
2828
* @property string|null $remember_token
2929
* @property CarbonImmutable|null $created_at
3030
* @property CarbonImmutable|null $updated_at
31+
* @property-read string $full_name
32+
* @property-read string $initials
3133
* @property-read DatabaseNotificationCollection<int, DatabaseNotification> $notifications
3234
* @property-read int|null $notifications_count
35+
* @property-read string|null $profile_image
3336
*
34-
* @method static UserFactory factory($count = null, $state = [])
37+
* @method static \Database\Factories\UserFactory factory($count = null, $state = [])
3538
* @method static Builder<static>|User newModelQuery()
3639
* @method static Builder<static>|User newQuery()
3740
* @method static Builder<static>|User query()

database/factories/UserFactory.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44

55
namespace Database\Factories;
66

7+
use App\Models\User;
78
use Illuminate\Database\Eloquent\Factories\Factory;
89
use Illuminate\Support\Facades\Hash;
910
use Illuminate\Support\Str;
1011

1112
/**
12-
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
13+
* @extends Factory<User>
1314
*/
1415
final class UserFactory extends Factory
1516
{
@@ -28,6 +29,7 @@ public function definition(): array
2829
return [
2930
'first_name' => fake()->name(),
3031
'last_name' => fake()->name(),
32+
'avatar' => fake()->imageUrl(),
3133
'email' => fake()->unique()->safeEmail(),
3234
'email_verified_at' => now(),
3335
'password' => self::$password ??= Hash::make('password'),

peck.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@
88
"nav",
99
"dropdown",
1010
"js",
11-
"filesystems"
11+
"filesystems",
12+
"eslint"
1213
],
1314
"paths": [
1415
"tests",
1516
"tsconfig.json",
1617
"resources/js/components/ui",
1718
"public/favicon.svg",
18-
"public/favicon.ico"
19+
"public/favicon.ico",
20+
"storage/debugbar"
1921
]
2022
}
2123
}

resources/js/pages/settings/Profile.vue

Lines changed: 98 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
<script lang="ts" setup>
22
import type { BreadcrumbItem, SharedData } from "@/types";
3-
4-
import { Head, Link, useForm, usePage } from "@inertiajs/vue3";
3+
import { Head, Link, router, useForm, usePage } from "@inertiajs/vue3";
4+
import { computed, ref, useTemplateRef } from "vue";
55
import DeleteUser from "@/components/DeleteUser.vue";
66
import HeadingSmall from "@/components/HeadingSmall.vue";
77
import InputError from "@/components/InputError.vue";
8+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
89
import { Button } from "@/components/ui/button";
910
import { Input } from "@/components/ui/input";
1011
import { Label } from "@/components/ui/label";
@@ -26,16 +27,63 @@ const breadcrumbs: BreadcrumbItem[] = [
2627
];
2728
2829
const page = usePage<SharedData>();
29-
const user = page.props.auth.user as App.Data.UserData;
30-
31-
const form = useForm({
32-
first_name: user.firstName,
33-
last_name: user.lastName,
34-
email: user.email,
30+
const user = computed(() => page.props.auth.user as App.Data.UserData);
31+
const profileImage = ref<string | null>(null);
32+
const photoInput = useTemplateRef<HTMLInputElement>("photo-input");
33+
34+
const form = useForm<{
35+
_method: string;
36+
first_name: string;
37+
last_name: string;
38+
email: string;
39+
profile_image?: File | null;
40+
}>({
41+
_method: "patch",
42+
first_name: user.value.firstName,
43+
last_name: user.value.lastName,
44+
email: user.value.email,
45+
profile_image: null,
3546
});
3647
48+
function selectNewPhoto() {
49+
photoInput.value?.click();
50+
}
51+
52+
function updatePhotoPreview() {
53+
const photo = photoInput.value?.files?.[0];
54+
55+
if (!photo) {
56+
return;
57+
}
58+
59+
form.profile_image = photo;
60+
const reader = new FileReader();
61+
62+
reader.onload = (e: ProgressEvent<FileReader>) => {
63+
profileImage.value = e.target?.result as string;
64+
};
65+
66+
reader.readAsDataURL(photo);
67+
}
68+
69+
function deletePhoto() {
70+
router.delete(route("profile-photo.destroy"), {
71+
preserveScroll: true,
72+
onSuccess: () => {
73+
profileImage.value = null;
74+
clearPhotoFileInput();
75+
},
76+
});
77+
}
78+
79+
function clearPhotoFileInput() {
80+
if (photoInput.value) {
81+
photoInput.value.value = "";
82+
}
83+
}
84+
3785
function submit() {
38-
form.patch(route("profile.update"), {
86+
form.post(route("profile.update"), {
3987
preserveScroll: true,
4088
});
4189
}
@@ -50,6 +98,47 @@ function submit() {
5098
<HeadingSmall description="Update your name and email address" title="Profile information" />
5199

52100
<form class="space-y-6" @submit.prevent="submit">
101+
<div class="grid gap-2">
102+
<input
103+
id="photo"
104+
ref="photo-input"
105+
accept="image/*"
106+
class="hidden"
107+
type="file"
108+
@change="updatePhotoPreview"
109+
>
110+
<div class="flex items-center gap-4">
111+
<Avatar class="h-20 w-20">
112+
<AvatarImage
113+
:alt="user.fullName"
114+
:src="profileImage ?? user.profileImage ?? ''"
115+
/>
116+
<AvatarFallback>
117+
{{ user.initials }}
118+
</AvatarFallback>
119+
</Avatar>
120+
<Button
121+
type="button"
122+
variant="outline"
123+
@click="selectNewPhoto"
124+
>
125+
Select photo
126+
</Button>
127+
<Button
128+
v-if="user.profileImage"
129+
type="button"
130+
variant="outline"
131+
@click="deletePhoto"
132+
>
133+
Remove photo
134+
</Button>
135+
</div>
136+
<InputError
137+
:message="form.errors.profile_image"
138+
class="mt-2"
139+
/>
140+
</div>
141+
53142
<div class="grid grid-cols-2 gap-6">
54143
<div class="grid gap-2">
55144
<Label for="first_name">First name</Label>

routes/settings.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use App\Http\Controllers\Settings\PasswordController;
66
use App\Http\Controllers\Settings\ProfileController;
7+
use App\Http\Controllers\Settings\ProfilePhotoController;
78
use Illuminate\Support\Facades\Route;
89
use Inertia\Inertia;
910

@@ -14,6 +15,8 @@
1415
Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update');
1516
Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
1617

18+
Route::delete('settings/profile-photo', [ProfilePhotoController::class, 'destroy'])->name('profile-photo.destroy');
19+
1720
Route::get('settings/password', [PasswordController::class, 'edit'])->name('password.edit');
1821
Route::put('settings/password', [PasswordController::class, 'update'])->name('password.update');
1922

tests/Feature/Settings/ProfileUpdateTest.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
namespace Tests\Feature\Settings;
66

77
use App\Models\User;
8+
use Illuminate\Http\UploadedFile;
9+
use Illuminate\Support\Facades\Storage;
810

911
describe('Profile updates', function (): void {
1012
it('can display the profile page', function (): void {
@@ -90,4 +92,59 @@
9092

9193
$this->assertNotNull($user->fresh());
9294
});
95+
96+
it('ensures profile photo can be uploaded', function (): void {
97+
$user = User::factory()->create();
98+
99+
Storage::fake('public');
100+
101+
$response = $this
102+
->actingAs($user)
103+
->patch('/settings/profile', [
104+
'first_name' => 'Test',
105+
'last_name' => 'User',
106+
'email' => $user->email,
107+
'profile_image' => UploadedFile::fake()->image('photo.jpg'),
108+
]);
109+
110+
$response
111+
->assertSessionHasNoErrors()
112+
->assertRedirect('/settings/profile');
113+
114+
$user->refresh();
115+
116+
expect($user->avatar)->not->toBeNull();
117+
expect(Storage::disk('public')->exists($user->avatar))->toBeTrue();
118+
});
119+
120+
it('ensures profile photo can be removed', function (): void {
121+
$user = User::factory()->create();
122+
123+
Storage::fake('public');
124+
125+
$response = $this->actingAs($user)->patch('/settings/profile', [
126+
'first_name' => 'Test',
127+
'last_name' => 'User',
128+
'email' => $user->email,
129+
'profile_image' => UploadedFile::fake()->image('photo.jpg'),
130+
]);
131+
132+
$response->assertSessionHasNoErrors()
133+
->assertRedirect('/settings/profile');
134+
135+
$user->refresh();
136+
$this->assertNotNull($user->avatar);
137+
$this->assertTrue(Storage::disk('public')->exists($user->avatar));
138+
139+
$oldPath = $user->avatar;
140+
141+
$response = $this->actingAs($user)->delete('/settings/profile-photo');
142+
143+
$response->assertSessionHasNoErrors()
144+
->assertRedirect('/settings/profile');
145+
146+
$user->refresh();
147+
$this->assertNull($user->avatar);
148+
$this->assertFalse(Storage::disk('public')->exists($oldPath));
149+
});
93150
});

0 commit comments

Comments
 (0)