Skip to content

Feature/avatar refactor #92

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions app/Http/Controllers/Settings/ProfileController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
use Inertia\Response;

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
{
Expand All @@ -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
{
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Requests/Settings/ProfileUpdateRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']
];
}
}
27 changes: 27 additions & 0 deletions app/Models/Traits/HasProfilePhoto.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace App\Models\Traits;

use Illuminate\Support\Facades\Storage;

trait HasProfilePhoto
{
/**
* Get the URL to the user's profile photo.
*/
public function getProfilePhotoUrlAttribute(): ?string
{
return $this->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;
}

}
14 changes: 13 additions & 1 deletion app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
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;

class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
use HasFactory, Notifiable, HasProfilePhoto;

/**
* The attributes that are mass assignable.
Expand All @@ -21,6 +22,7 @@ class User extends Authenticatable
'name',
'email',
'password',
'profile_photo_path',
];

/**
Expand All @@ -31,6 +33,16 @@ class User extends Authenticatable
protected $hidden = [
'password',
'remember_token',
'profile_photo_path',
];

/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $appends = [
'avatar',
];

/**
Expand Down
5 changes: 4 additions & 1 deletion database/factories/UserFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
];
}

Expand All @@ -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,
]);
}
Expand Down
4 changes: 2 additions & 2 deletions database/migrations/0001_01_01_000000_create_users_table.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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();
});
Expand Down
5 changes: 1 addition & 4 deletions resources/js/components/NavMain.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,7 @@ const page = usePage<SharedData>();
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton
as-child :is-active="item.href === page.url"
:tooltip="item.title"
>
<SidebarMenuButton as-child :is-active="item.href === page.url" :tooltip="item.title">
<Link :href="item.href">
<component :is="item.icon" />
<span>{{ item.title }}</span>
Expand Down
6 changes: 3 additions & 3 deletions resources/js/components/NavUser.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ const { isMobile, state } = useSidebar();
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
<DropdownMenuContent
class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
:side="isMobile ? 'bottom' : state === 'collapsed' ? 'left' : 'bottom'"
align="end"
align="end"
:side-offset="4"
>
<UserMenuContent :user="user" />
Expand Down
109 changes: 104 additions & 5 deletions resources/js/pages/settings/Profile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,27 +44,110 @@ const breadcrumbs: BreadcrumbItem[] = [
const page = usePage<SharedData>();
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;
});
</script>

<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Profile settings" />

<SettingsLayout>
<div class="flex flex-col space-y-6">
<HeadingSmall title="Profile information" description="Update your name and email address" />

<HeadingSmall title="Profile information" description="Update your name, email and avatar" />
<form @submit.prevent="submit" class="space-y-6">
<div class="flex items-center gap-4">
<div class="group relative">
<label for="avatar-upload" class="cursor-pointer" aria-label="Upload avatar">
<Avatar size="lg">
<AvatarImage :src="avatarSrc || user.avatar || ''" alt="User avatar" />
<AvatarFallback>{{ getInitials(user.name) }}</AvatarFallback>
</Avatar>
</label>
<input id="avatar-upload" type="file" accept="image/*" class="hidden" @change="handleAvatarChange" />
</div>
<div>
<p class="text-sm text-muted-foreground">Click the avatar to upload a new photo.</p>
<p class="text-sm text-muted-foreground">Max size: 2MB</p>
<Dialog>
<DialogTrigger as-child>
<Button
v-if="user.avatar || avatarSrc"
type="button"
variant="destructive"
size="sm"
class="mt-2"
aria-label="Open remove avatar dialog"
>
<Trash2 /> Remove avatar
</Button>
</DialogTrigger>

<DialogContent>
<DialogHeader>
<DialogTitle>Remove profile photo?</DialogTitle>
<DialogDescription>
<p class="mt-4">
Don’t worry — this just removes the photo from preview. Nothing is permanent until you hit “Save”.
</p>
<p class="mt-4">You can reload the page to bring it back.</p>
</DialogDescription>
</DialogHeader>

<DialogFooter class="gap-2">
<DialogClose as-child>
<Button variant="secondary">Cancel</Button>
</DialogClose>

<DialogClose as-child>
<Button variant="destructive" @click="removeAvatar"> Yes, remove it </Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
<InputError :message="form.errors.profile_photo" class="mt-1" />

<div class="grid gap-2">
<Label for="name">Name</Label>
<Input id="name" class="mt-1 block w-full" v-model="form.name" required autocomplete="name" placeholder="Full name" />
Expand All @@ -59,8 +158,8 @@ const submit = () => {
<Label for="email">Email address</Label>
<Input
id="email"
type="email"
class="mt-1 block w-full"
type="email"
v-model="form.email"
required
autocomplete="username"
Expand Down
30 changes: 30 additions & 0 deletions tests/Feature/Settings/ProfileUpdateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,34 @@ public function test_correct_password_must_be_provided_to_delete_account()

$this->assertNotNull($user->fresh());
}

public function test_user_can_upload_and_remove_avatar()
{
\Storage::fake('public');

$user = User::factory()->create();

$response = $this->actingAs($user)->patch('/settings/profile', [
'name' => 'Test User',
'email' => $user->email,
'profile_photo' => \Illuminate\Http\UploadedFile::fake()->image('avatar.jpg'),
]);

$response->assertRedirect('/settings/profile');

$user->refresh();
$this->assertNotNull($user->profile_photo_path);
\Storage::disk('public')->assertExists($user->profile_photo_path);

$response = $this->actingAs($user)->patch('/settings/profile', [
'name' => 'Test User',
'email' => $user->email,
'remove_avatar' => '1',
]);

$response->assertRedirect('/settings/profile');

$user->refresh();
$this->assertNull($user->profile_photo_path);
}
}