diff --git a/app/Http/Controllers/ChannelController.php b/app/Http/Controllers/ChannelController.php index 4ba89cf..cc8826d 100644 --- a/app/Http/Controllers/ChannelController.php +++ b/app/Http/Controllers/ChannelController.php @@ -81,6 +81,7 @@ class ChannelController extends Controller 'user' => [ 'id' => $message->user->id, 'name' => $message->user->name, + 'avatar_url' => $message->user->avatar, ], ]), 'isFollowing' => $request->user() diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php index d19f963..2673f6d 100644 --- a/app/Http/Controllers/Settings/ProfileController.php +++ b/app/Http/Controllers/Settings/ProfileController.php @@ -9,6 +9,7 @@ use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Storage; use Inertia\Inertia; use Inertia\Response; @@ -30,13 +31,26 @@ class ProfileController extends Controller */ public function update(ProfileUpdateRequest $request): RedirectResponse { - $request->user()->fill($request->validated()); + $validated = $request->validated(); + $user = $request->user(); - if ($request->user()->isDirty('email')) { - $request->user()->email_verified_at = null; + if ($request->hasFile('avatar')) { + if ($user->avatar_path) { + Storage::disk('public')->delete($user->avatar_path); + } + + $validated['avatar_path'] = $request->file('avatar')->store("users/{$user->id}/avatars", 'public'); } - $request->user()->save(); + unset($validated['avatar']); + + $user->fill($validated); + + if ($user->isDirty('email')) { + $user->email_verified_at = null; + } + + $user->save(); Inertia::flash('toast', ['type' => 'success', 'message' => __('Profile updated.')]); @@ -52,6 +66,10 @@ class ProfileController extends Controller Auth::logout(); + if ($user->avatar_path) { + Storage::disk('public')->delete($user->avatar_path); + } + $user->delete(); $request->session()->invalidate(); diff --git a/app/Http/Requests/Settings/ProfileUpdateRequest.php b/app/Http/Requests/Settings/ProfileUpdateRequest.php index e4eb8d8..2b0022c 100644 --- a/app/Http/Requests/Settings/ProfileUpdateRequest.php +++ b/app/Http/Requests/Settings/ProfileUpdateRequest.php @@ -17,6 +17,9 @@ class ProfileUpdateRequest extends FormRequest */ public function rules(): array { - return $this->profileRules($this->user()->id); + return [ + ...$this->profileRules($this->user()->id), + 'avatar' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; } } diff --git a/app/Models/User.php b/app/Models/User.php index bd787b8..74be875 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,15 +6,17 @@ namespace App\Models; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Facades\Storage; use Laravel\Fortify\TwoFactorAuthenticatable; -#[Fillable(['name', 'email', 'password', 'is_admin', 'can_create_channel', 'suspended_at'])] -#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] +#[Fillable(['name', 'email', 'password', 'avatar_path', 'is_admin', 'can_create_channel', 'suspended_at'])] +#[Hidden(['password', 'avatar_path', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] class User extends Authenticatable { /** @use HasFactory */ @@ -24,6 +26,10 @@ class User extends Authenticatable 'can_create_channel' => false, ]; + protected $appends = [ + 'avatar', + ]; + /** * Get the attributes that should be cast. * @@ -60,4 +66,12 @@ class User extends Authenticatable { return $this->can_create_channel || PlatformSetting::current()->channel_creation_open; } + + /** + * @return Attribute + */ + protected function avatar(): Attribute + { + return Attribute::get(fn (): ?string => $this->avatar_path ? Storage::url($this->avatar_path) : null); + } } diff --git a/database/migrations/2026_05_16_001842_add_avatar_path_to_users_table.php b/database/migrations/2026_05_16_001842_add_avatar_path_to_users_table.php new file mode 100644 index 0000000..8a9fe5b --- /dev/null +++ b/database/migrations/2026_05_16_001842_add_avatar_path_to_users_table.php @@ -0,0 +1,22 @@ +string('avatar_path')->nullable()->after('password'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('avatar_path'); + }); + } +}; diff --git a/resources/js/components/app-header.tsx b/resources/js/components/app-header.tsx index 1be6230..c158128 100644 --- a/resources/js/components/app-header.tsx +++ b/resources/js/components/app-header.tsx @@ -199,7 +199,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) { > diff --git a/resources/js/components/user-info.tsx b/resources/js/components/user-info.tsx index dd0848e..eb26006 100644 --- a/resources/js/components/user-info.tsx +++ b/resources/js/components/user-info.tsx @@ -14,7 +14,7 @@ export function UserInfo({ return ( <> - + {getInitials(user.name)} diff --git a/resources/js/pages/channels/show.tsx b/resources/js/pages/channels/show.tsx index 7a692c4..3720ae2 100644 --- a/resources/js/pages/channels/show.tsx +++ b/resources/js/pages/channels/show.tsx @@ -10,6 +10,11 @@ import { } from 'lucide-react'; import type { FormEvent } from 'react'; import { useState } from 'react'; +import { + Avatar as ChatAvatar, + AvatarFallback, + AvatarImage, +} from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { @@ -20,6 +25,7 @@ import { SheetTrigger, } from '@/components/ui/sheet'; import { VideoPlayer } from '@/components/video-player'; +import { useInitials } from '@/hooks/use-initials'; import { cn } from '@/lib/utils'; import { home, login } from '@/routes'; import { follow, unfollow } from '@/routes/channels'; @@ -278,6 +284,8 @@ function ChatPanel({ onSubmit: (event: FormEvent) => void; compact?: boolean; }) { + const getInitials = useInitials(); + return (
{messages.map((message) => ( -
- {message.user.name} - - {message.body} - +
+ + + + {getInitials(message.user.name)} + + +
+
+ {message.user.name} +
+
+ {message.body} +
+
))} {messages.length === 0 && ( diff --git a/resources/js/pages/settings/profile.tsx b/resources/js/pages/settings/profile.tsx index cc4b19d..35d5e3a 100644 --- a/resources/js/pages/settings/profile.tsx +++ b/resources/js/pages/settings/profile.tsx @@ -1,11 +1,15 @@ import { Form, Head, Link, usePage } from '@inertiajs/react'; +import { ImagePlus } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; import DeleteUser from '@/components/delete-user'; import Heading from '@/components/heading'; import InputError from '@/components/input-error'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { useInitials } from '@/hooks/use-initials'; import { edit } from '@/routes/profile'; import { send } from '@/routes/verification'; @@ -18,11 +22,34 @@ export default function Profile({ }) { const { auth } = usePage().props; const user = auth.user; + const getInitials = useInitials(); + const avatarInputRef = useRef(null); + const [avatarPreviewUrl, setAvatarPreviewUrl] = useState( + null, + ); + + useEffect(() => { + return () => { + if (avatarPreviewUrl) { + URL.revokeObjectURL(avatarPreviewUrl); + } + }; + }, [avatarPreviewUrl]); if (!user) { return null; } + function previewAvatar(file: File | null) { + setAvatarPreviewUrl((currentUrl) => { + if (currentUrl) { + URL.revokeObjectURL(currentUrl); + } + + return file ? URL.createObjectURL(file) : null; + }); + } + return ( <> @@ -33,7 +60,7 @@ export default function Profile({
{ + setAvatarPreviewUrl(null); + + if (avatarInputRef.current) { + avatarInputRef.current.value = ''; + } + }} + encType="multipart/form-data" className="space-y-6" > - {({ processing, errors }) => ( + {({ processing, progress, errors }) => ( <> +
+ + + + {getInitials(user.name)} + + + +
+ + + previewAvatar( + event.target.files?.[0] ?? null, + ) + } + /> +
+ + + JPG, PNG, or WebP up to 2 MB. + +
+ {progress && ( + + )} + +
+
+
diff --git a/resources/js/types/auth.ts b/resources/js/types/auth.ts index 0d1a740..841f650 100644 --- a/resources/js/types/auth.ts +++ b/resources/js/types/auth.ts @@ -2,7 +2,7 @@ export type User = { id: number; name: string; email: string; - avatar?: string; + avatar: string | null; email_verified_at: string | null; two_factor_enabled?: boolean; created_at: string; diff --git a/resources/js/types/streaming.ts b/resources/js/types/streaming.ts index eef2c43..761a076 100644 --- a/resources/js/types/streaming.ts +++ b/resources/js/types/streaming.ts @@ -72,5 +72,6 @@ export type ChatMessage = { user: { id: number; name: string; + avatar_url: string | null; }; }; diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php index e6c95ce..39a5954 100644 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ b/tests/Feature/Settings/ProfileUpdateTest.php @@ -4,6 +4,9 @@ namespace Tests\Feature\Settings; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; +use Inertia\Testing\AssertableInertia as Assert; use Tests\TestCase; class ProfileUpdateTest extends TestCase @@ -21,6 +24,22 @@ class ProfileUpdateTest extends TestCase $response->assertOk(); } + public function test_profile_page_exposes_user_avatar_url(): void + { + $user = User::factory()->create([ + 'avatar_path' => 'users/demo/avatar.jpg', + ]); + + $this + ->actingAs($user) + ->get(route('profile.edit')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('settings/profile') + ->where('auth.user.avatar', '/storage/users/demo/avatar.jpg'), + ); + } + public function test_profile_information_can_be_updated() { $user = User::factory()->create(); @@ -61,9 +80,67 @@ class ProfileUpdateTest extends TestCase $this->assertNotNull($user->refresh()->email_verified_at); } + public function test_user_can_update_their_avatar(): void + { + Storage::fake('public'); + + $user = User::factory()->create([ + 'avatar_path' => 'users/demo/old-avatar.jpg', + ]); + + Storage::disk('public')->put($user->avatar_path, 'old avatar'); + + $this + ->actingAs($user) + ->post(route('profile.update'), [ + '_method' => 'PATCH', + 'name' => $user->name, + 'email' => $user->email, + 'avatar' => UploadedFile::fake()->image('avatar.png', 400, 400), + ]) + ->assertRedirect(route('profile.edit')) + ->assertSessionHasNoErrors(); + + $user->refresh(); + + $this->assertNotSame('users/demo/old-avatar.jpg', $user->avatar_path); + $this->assertStringStartsWith("users/{$user->id}/avatars/", $user->avatar_path); + Storage::disk('public')->assertMissing('users/demo/old-avatar.jpg'); + Storage::disk('public')->assertExists($user->avatar_path); + } + + public function test_user_avatar_must_be_an_image(): void + { + Storage::fake('public'); + + $user = User::factory()->create([ + 'avatar_path' => 'users/demo/current-avatar.jpg', + ]); + + $this + ->actingAs($user) + ->from(route('profile.edit')) + ->post(route('profile.update'), [ + '_method' => 'PATCH', + 'name' => $user->name, + 'email' => $user->email, + 'avatar' => UploadedFile::fake()->create('avatar.txt', 8, 'text/plain'), + ]) + ->assertRedirect(route('profile.edit')) + ->assertSessionHasErrors('avatar'); + + $this->assertSame('users/demo/current-avatar.jpg', $user->refresh()->avatar_path); + } + public function test_user_can_delete_their_account() { - $user = User::factory()->create(); + Storage::fake('public'); + + $user = User::factory()->create([ + 'avatar_path' => 'users/demo/avatar.jpg', + ]); + + Storage::disk('public')->put($user->avatar_path, 'avatar'); $response = $this ->actingAs($user) @@ -77,6 +154,7 @@ class ProfileUpdateTest extends TestCase $this->assertGuest(); $this->assertNull($user->fresh()); + Storage::disk('public')->assertMissing('users/demo/avatar.jpg'); } public function test_correct_password_must_be_provided_to_delete_account() diff --git a/tests/Feature/ViewerInteractionTest.php b/tests/Feature/ViewerInteractionTest.php index 7f6d60a..2750d5f 100644 --- a/tests/Feature/ViewerInteractionTest.php +++ b/tests/Feature/ViewerInteractionTest.php @@ -30,7 +30,9 @@ class ViewerInteractionTest extends TestCase public function test_authenticated_user_can_chat_while_channel_is_live(): void { - $viewer = User::factory()->create(); + $viewer = User::factory()->create([ + 'avatar_path' => 'users/demo/avatar.jpg', + ]); $channel = Channel::factory()->create(['slug' => 'demo', 'is_live' => true]); $broadcast = Broadcast::factory()->for($channel)->live()->create(); $channel->forceFill(['live_broadcast_id' => $broadcast->id])->save(); @@ -47,6 +49,14 @@ class ViewerInteractionTest extends TestCase 'user_id' => $viewer->id, 'body' => 'Hello chat', ]); + + $this->actingAs($viewer) + ->get(route('channels.show', $channel)) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('channels/show') + ->where('chatMessages.0.user.avatar_url', '/storage/users/demo/avatar.jpg'), + ); } public function test_anonymous_users_can_watch_channel_page_but_not_chat(): void