diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php index a6cb7e16..7d80d704 100644 --- a/app/Http/Controllers/Settings/ProfileController.php +++ b/app/Http/Controllers/Settings/ProfileController.php @@ -37,6 +37,10 @@ public function update(ProfileUpdateRequest $request): RedirectResponse $request->user()->save(); + if ($request->hasFile('photo')) { + $request->user()->updateProfilePhoto($request->validated('photo')); + } + return to_route('profile.edit'); } @@ -53,6 +57,8 @@ public function destroy(Request $request): RedirectResponse Auth::logout(); + $user->deleteProfilePhoto(); + $user->delete(); $request->session()->invalidate(); diff --git a/app/Http/Controllers/Settings/ProfilePhotoController.php b/app/Http/Controllers/Settings/ProfilePhotoController.php new file mode 100644 index 00000000..2c27ed20 --- /dev/null +++ b/app/Http/Controllers/Settings/ProfilePhotoController.php @@ -0,0 +1,23 @@ +user()->deleteProfilePhoto(); + + return to_route('profile.edit'); + } +} diff --git a/app/Http/Requests/Settings/ProfileUpdateRequest.php b/app/Http/Requests/Settings/ProfileUpdateRequest.php index 64cf26b0..d9888675 100644 --- a/app/Http/Requests/Settings/ProfileUpdateRequest.php +++ b/app/Http/Requests/Settings/ProfileUpdateRequest.php @@ -27,6 +27,8 @@ public function rules(): array 'max:255', Rule::unique(User::class)->ignore($this->user()->id), ], + + 'photo' => ['nullable', 'image', 'mimes:jpg,jpeg,png', 'max:2048'], ]; } } diff --git a/app/Models/User.php b/app/Models/User.php index 749c7b77..0a3c410f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,11 +6,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use App\Traits\HasProfilePhoto; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasFactory, Notifiable, HasProfilePhoto; /** * The attributes that are mass assignable. @@ -33,6 +34,15 @@ class User extends Authenticatable 'remember_token', ]; + /** + * The accessors to append to the model's array form. + * + * @var array + */ + protected $appends = [ + 'avatar', + ]; + /** * Get the attributes that should be cast. * diff --git a/app/Traits/HasProfilePhoto.php b/app/Traits/HasProfilePhoto.php new file mode 100644 index 00000000..ad1204b2 --- /dev/null +++ b/app/Traits/HasProfilePhoto.php @@ -0,0 +1,79 @@ +profile_photo_path, function ($previous) use ($photo, $storagePath) { + $this->forceFill([ + 'profile_photo_path' => $photo->storePublicly( + $storagePath, ['disk' => $this->profilePhotoDisk()] + ), + ])->save(); + + if ($previous) { + Storage::disk($this->profilePhotoDisk())->delete($previous); + } + }); + } + + /** + * Delete the user's profile photo. + * + * @return void + */ + public function deleteProfilePhoto(): void + { + if (is_null($this->profile_photo_path)) { + return; + } + + Storage::disk($this->profilePhotoDisk())->delete($this->profile_photo_path); + + $this->forceFill([ + 'profile_photo_path' => null, + ])->save(); + } + + /** + * Get the URL to the user's profile photo. + * + * @return \Illuminate\Database\Eloquent\Casts\Attribute + */ + protected function avatar(): Attribute + { + return Attribute::make( + get: function ($value) { + return $this->profile_photo_path + ? Storage::disk($this->profilePhotoDisk())->url($this->profile_photo_path) + : null; + }, + set: function ($value) { + return ['profile_photo_path' => $value]; + } + ); + } + + /** + * Get the disk that profile photos should be stored on. + * + * @return string + */ + protected function profilePhotoDisk(): string + { + return 'public'; + } +} \ No newline at end of file diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 584104c9..31cb74bd 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -29,6 +29,7 @@ public function definition(): array 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), + 'profile_photo_path' => 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..b1d855f0 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -18,6 +18,7 @@ public function up(): void $table->timestamp('email_verified_at')->nullable(); $table->string('password'); $table->rememberToken(); + $table->string('profile_photo_path', 2048)->nullable(); $table->timestamps(); }); diff --git a/package-lock.json b/package-lock.json index 8e1bb6f5..c0ae30ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "lucide-react": "^0.475.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-image-crop": "^11.0.7", "tailwind-merge": "^3.0.1", "tailwindcss": "^4.0.0", "tailwindcss-animate": "^1.0.7", @@ -332,390 +333,6 @@ "node": ">=6.9.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", - "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", - "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", - "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", - "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", - "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", - "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", - "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", - "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", - "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", - "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", - "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", - "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", - "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", - "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", - "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", - "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", - "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", - "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", - "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", - "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", - "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", - "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", - "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", - "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@esbuild/win32-x64": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", @@ -2502,9 +2119,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.10.tgz", - "integrity": "sha512-Xe15DqfzcYzozbhhgTUeZNnmnr56HdnqeollvLumxKvrCicDFkeZimz299Czyw4GeRUHZgcdccwr+Do3/Y2aZA==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.15.tgz", + "integrity": "sha512-g76GxlKH124RuGqacCEFc2nbzRl7bBrlC8qDQMiUABkiifDRHOIUjgKbLNG4RuR9hQAD/MKsqZ7A8L08zsoBrw==", "cpu": [ "x64" ], @@ -2565,6 +2182,22 @@ "node": ">= 10" } }, + "node_modules/@tailwindcss/oxide/node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.10.tgz", + "integrity": "sha512-Xe15DqfzcYzozbhhgTUeZNnmnr56HdnqeollvLumxKvrCicDFkeZimz299Czyw4GeRUHZgcdccwr+Do3/Y2aZA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@tailwindcss/vite": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.0.10.tgz", @@ -5350,9 +4983,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.1.tgz", - "integrity": "sha512-u1S+xdODy/eEtjADqirA774y3jLcm8RPtYztwReEXoZKdzgsHYPl0s5V52Tst+GKzqjebkULT86XMSxejzfISw==", + "version": "1.29.3", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.3.tgz", + "integrity": "sha512-ySZTNCpbfbK8rqpKJeJR2S0g/8UqqV3QnzcuWvpI60LWxnFN91nxpSSwCbzfOXkzKfar9j5eOuOplf+klKtINg==", "cpu": [ "x64" ], @@ -5429,6 +5062,26 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lightningcss/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.1.tgz", + "integrity": "sha512-u1S+xdODy/eEtjADqirA774y3jLcm8RPtYztwReEXoZKdzgsHYPl0s5V52Tst+GKzqjebkULT86XMSxejzfISw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -6077,6 +5730,15 @@ "react": "^19.0.0" } }, + "node_modules/react-image-crop": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.0.7.tgz", + "integrity": "sha512-ZciKWHDYzmm366JDL18CbrVyjnjH0ojufGDmScfS4ZUqLHg4nm6ATY+K62C75W4ZRNt4Ii+tX0bSjNk9LQ2xzQ==", + "license": "ISC", + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index 20229aa1..23018d42 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "lucide-react": "^0.475.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-image-crop": "^11.0.7", "tailwind-merge": "^3.0.1", "tailwindcss": "^4.0.0", "tailwindcss-animate": "^1.0.7", diff --git a/resources/js/pages/settings/profile.tsx b/resources/js/pages/settings/profile.tsx index 7be605f0..dfe5d270 100644 --- a/resources/js/pages/settings/profile.tsx +++ b/resources/js/pages/settings/profile.tsx @@ -1,7 +1,9 @@ import { type BreadcrumbItem, type SharedData } from '@/types'; import { Transition } from '@headlessui/react'; -import { Head, Link, useForm, usePage } from '@inertiajs/react'; -import { FormEventHandler } from 'react'; +import { Head, Link, router, useForm, usePage } from '@inertiajs/react'; +import { FormEventHandler, useRef, useState } from 'react'; +import ReactCrop, { type Crop } from 'react-image-crop'; +import 'react-image-crop/dist/ReactCrop.css'; import DeleteUser from '@/components/delete-user'; import HeadingSmall from '@/components/heading-small'; @@ -11,6 +13,10 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; +import { useInitials } from '@/hooks/use-initials'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { CropIcon, Trash2Icon } from 'lucide-react'; const breadcrumbs: BreadcrumbItem[] = [ { @@ -20,23 +26,130 @@ const breadcrumbs: BreadcrumbItem[] = [ ]; type ProfileForm = { + _method: string; name: string; email: string; + photo?: File | null; } export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail: boolean; status?: string }) { const { auth } = usePage().props; + const getInitials = useInitials(); - const { data, setData, patch, errors, processing, recentlySuccessful } = useForm>({ + const [photoPreview, setPhotoPreview] = useState(null); + const [originalImage, setOriginalImage] = useState(null); + const [isCropperOpen, setIsCropperOpen] = useState(false); + const [crop, setCrop] = useState({ + unit: '%', + width: 100, + height: 100, + x: 0, + y: 0, + }); + + const photoInput = useRef(null); + const imageRef = useRef(null); + + const { data, setData, post, errors, processing, recentlySuccessful } = useForm>({ + _method: 'patch', name: auth.user.name, email: auth.user.email, + photo: null, }); + const selectNewPhoto = () => { + photoInput.current?.click(); + }; + + const updatePhotoPreview = () => { + const photo = photoInput.current?.files?.[0]; + + if (!photo) return; + + const reader = new FileReader(); + + reader.onload = (e: ProgressEvent) => { + const result = e.target?.result as string; + setOriginalImage(result); + setIsCropperOpen(true); + }; + + reader.readAsDataURL(photo); + }; + + const deletePhoto = () => { + router.delete(route('profile-photo.destroy'), { + preserveScroll: true, + onSuccess: () => { + setPhotoPreview(null); + setOriginalImage(null); + clearPhotoFileInput(); + }, + }); + }; + + const clearPhotoFileInput = () => { + if (photoInput.current) { + photoInput.current.value = ''; + } + }; + + const completeCrop = async () => { + if (!imageRef.current || !crop.width || !crop.height) return; + + const canvas = document.createElement('canvas'); + const scaleX = imageRef.current.naturalWidth / imageRef.current.width; + const scaleY = imageRef.current.naturalHeight / imageRef.current.height; + const pixelRatio = window.devicePixelRatio; + + canvas.width = crop.width * scaleX * pixelRatio; + canvas.height = crop.height * scaleY * pixelRatio; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); + ctx.imageSmoothingQuality = 'high'; + + ctx.drawImage( + imageRef.current, + crop.x * scaleX, + crop.y * scaleY, + crop.width * scaleX, + crop.height * scaleY, + 0, + 0, + crop.width * scaleX, + crop.height * scaleY + ); + + // Convert canvas to blob + const croppedImageUrl = canvas.toDataURL('image/jpeg'); + setPhotoPreview(croppedImageUrl); + setIsCropperOpen(false); + + // Convert data URL to Blob + const response = await fetch(croppedImageUrl); + const blob = await response.blob(); + + // Create a File from Blob + const fileName = photoInput.current?.files?.[0]?.name || 'cropped-image.jpg'; + const croppedFile = new File([blob], fileName, { type: 'image/jpeg' }); + + setData('photo', croppedFile); + }; + + const cancelCrop = () => { + setIsCropperOpen(false); + clearPhotoFileInput(); + }; + const submit: FormEventHandler = (e) => { e.preventDefault(); - patch(route('profile.update'), { + post(route('profile.update'), { preserveScroll: true, + onSuccess: () => clearPhotoFileInput(), }); }; @@ -49,6 +162,30 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
+
+ + + + +
+ + + {getInitials(auth.user.name)} + + + + + {(auth.user.avatar || photoPreview) && ( + + )} +
+ +
+
@@ -121,6 +258,42 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
+ + + + + Crop your profile photo + + +
+ {originalImage && ( + setCrop(c)} + circularCrop + aspect={1} + > + Crop preview + + )} + +
+ + +
+
+
+
+ ); diff --git a/routes/settings.php b/routes/settings.php index 95031371..cf6b1f80 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Settings\PasswordController; use App\Http\Controllers\Settings\ProfileController; +use App\Http\Controllers\Settings\ProfilePhotoController; use Illuminate\Support\Facades\Route; use Inertia\Inertia; @@ -11,6 +12,7 @@ Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); + Route::delete('settings/profile-photo', [ProfilePhotoController::class, 'destroy'])->name('profile-photo.destroy'); Route::get('settings/password', [PasswordController::class, 'edit'])->name('password.edit'); Route::put('settings/password', [PasswordController::class, 'update'])->name('password.update'); diff --git a/tests/Feature/Settings/ProfilePhotoTest.php b/tests/Feature/Settings/ProfilePhotoTest.php new file mode 100644 index 00000000..41a16f09 --- /dev/null +++ b/tests/Feature/Settings/ProfilePhotoTest.php @@ -0,0 +1,103 @@ +create(); + + Storage::fake('public'); + + $response = $this + ->actingAs($user) + ->patch('/settings/profile', [ + 'name' => $user->name, + 'email' => $user->email, + 'photo' => $file = UploadedFile::fake()->image('photo.jpg'), + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/settings/profile'); + + $user->refresh(); + + $this->assertNotNull($user->profile_photo_path); + $this->assertTrue(Storage::disk('public')->exists($user->profile_photo_path)); + } + + public function test_profile_photo_can_be_removed() + { + $user = User::factory()->create(); + + Storage::fake('public'); + + $response = $this->actingAs($user)->patch('/settings/profile', [ + 'name' => $user->name, + 'email' => $user->email, + 'photo' => $file = UploadedFile::fake()->image('photo.jpg'), + ]); + + $response->assertSessionHasNoErrors() + ->assertRedirect('/settings/profile'); + + $user->refresh(); + + $this->assertNotNull($user->profile_photo_path); + $this->assertTrue(Storage::disk('public')->exists($user->profile_photo_path)); + + $oldPath = $user->profile_photo_path; + + $response = $this->actingAs($user)->delete('/settings/profile-photo'); + + $response->assertSessionHasNoErrors() + ->assertRedirect('/settings/profile'); + + $user->refresh(); + + $this->assertNull($user->profile_photo_path); + $this->assertFalse(Storage::disk('public')->exists($oldPath)); + } + + public function test_profile_photo_can_be_updated() + { + $user = User::factory()->create(); + + Storage::fake('public'); + + $this->actingAs($user)->patch('/settings/profile', [ + 'name' => $user->name, + 'email' => $user->email, + 'photo' => UploadedFile::fake()->image('initial.jpg'), + ]); + + $user->refresh(); + $oldPath = $user->profile_photo_path; + + $response = $this->actingAs($user)->patch('/settings/profile', [ + 'name' => $user->name, + 'email' => $user->email, + 'photo' => UploadedFile::fake()->image('updated.jpg'), + ]); + + $response->assertSessionHasNoErrors() + ->assertRedirect('/settings/profile'); + + $user->refresh(); + + $this->assertNotNull($user->profile_photo_path); + $this->assertNotEquals($oldPath, $user->profile_photo_path); + $this->assertTrue(Storage::disk('public')->exists($user->profile_photo_path)); + $this->assertFalse(Storage::disk('public')->exists($oldPath)); + } +} \ No newline at end of file